Initial
EK-DOS-WEB bauen und ausrollen / frontend (push) Failing after 22s
EK-DOS-WEB bauen und ausrollen / backend (push) Failing after 27s
EK-DOS-WEB bauen und ausrollen / deploy (push) Skipped

This commit is contained in:
Kyle Müller
2026-09-06 13:25:44 +02:00
commit 7d82e807fc
71 changed files with 10601 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# EK-DOS-WEB Backend Beispielkonfiguration.
# Kopieren nach .env und ausfuellen. Die Datei gehoert NICHT ins Repository.
APP_ENV=production
APP_DEBUG=false
# ── PostgreSQL: Benutzer, Rollen, Protokoll ────────────────────────────────
DB_DSN=pgsql:host=127.0.0.1;port=5432;dbname=ekdos
DB_USER=ekdos
DB_PASSWORD=
# ── Redis: Sitzungen (BFF-Ticket-Store) und n8n-Antwort-Cache ─────────────
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_PREFIX=ekdos:
# ── n8n ───────────────────────────────────────────────────────────────────
# Der Dienst auf Port 5678 ist ausschliesslich ueber den CNAME erreichbar.
N8N_PUBLIC_BASE=https://n8n.elektro-krueger.eu
# Jeder weitere n8n-Dienst auf einem anderen Port wird ueber die RFC1918-Adresse
# angesprochen, z. B. N8n::internal(5679, '/webhook/...') -> http://10.0.11.131:5679/...
N8N_INTERNAL_HOST=10.0.11.131
N8N_TIMEOUT=15
N8N_CONNECT_TIMEOUT=5
# Geteilte Geheimnisse fuer geschuetzte n8n-Workflows.
N8N_CUSTOMER_KEY=
N8N_INVOICE_SYNC_SECRET=
# Optional: erlaubt n8n, den Cache aktiv zu verwerfen (POST /api/tickets/refresh).
N8N_REFRESH_SECRET=
# ── Sitzung ───────────────────────────────────────────────────────────────
SESSION_COOKIE=ekdos_session
# Leerlauf in Sekunden, danach ist eine erneute Anmeldung noetig.
SESSION_TTL=3600
# Hinter TLS immer true lassen. Nur fuer reines HTTP im LAN auf false setzen.
SESSION_COOKIE_SECURE=true
# ── Anmeldebremse (Redis) ─────────────────────────────────────────────────
LOGIN_MAX_ATTEMPTS=10
LOGIN_WINDOW=900
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.env
composer.lock.bak
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Wartungs-CLI.
*
* php bin/ekdos migrate Schema anlegen oder fortschreiben
* php bin/ekdos user:create Benutzer anlegen (Passwort wird abgefragt)
* php bin/ekdos user:list Benutzer auflisten
* php bin/ekdos user:password <name> Passwort zuruecksetzen
* php bin/ekdos cache:flush n8n-Cache leeren
* php bin/ekdos check Postgres, Redis und n8n pruefen
*
* Der erste Administrator wird mit user:create angelegt. Es gibt bewusst keinen
* Bootstrap-Benutzer aus der Umgebung: ein Passwort in einer .env ist ein
* Passwort, das niemand mehr aendert.
*/
use Ekdos\Auth\AuthService;
use Ekdos\Bootstrap\Container;
use Ekdos\N8n\Cache;
use Ekdos\N8n\Client;
use Ekdos\N8n\Endpoints;
use Ekdos\Support\Config;
use Ekdos\Users\Role;
use Ekdos\Users\UserRepository;
require __DIR__ . '/../vendor/autoload.php';
$projectRoot = dirname(__DIR__);
$command = $argv[1] ?? 'help';
function out(string $line = ''): void
{
fwrite(STDOUT, $line . PHP_EOL);
}
function fail(string $line): never
{
fwrite(STDERR, $line . PHP_EOL);
exit(1);
}
/** Liest eine Eingabe ohne Bildschirmausgabe. */
function readSecret(string $prompt): string
{
fwrite(STDERR, $prompt);
if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) {
shell_exec('stty -echo 2>/dev/null');
}
$value = trim((string) fgets(STDIN));
if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) {
shell_exec('stty echo 2>/dev/null');
}
fwrite(STDERR, PHP_EOL);
return $value;
}
function ask(string $prompt, string $fallback = ''): string
{
fwrite(STDERR, $prompt);
$value = trim((string) fgets(STDIN));
return $value !== '' ? $value : $fallback;
}
$container = Container::build($projectRoot);
switch ($command) {
case 'migrate':
/** @var PDO $db */
$db = $container->get(PDO::class);
$db->exec('create table if not exists schema_migrations (version text primary key, applied_at timestamptz not null default now())');
$applied = $db->query('select version from schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
$files = glob($projectRoot . '/migrations/*.sql') ?: [];
sort($files);
$ran = 0;
foreach ($files as $file) {
$version = basename($file, '.sql');
if (in_array($version, $applied, true)) {
continue;
}
$sql = file_get_contents($file);
if ($sql === false) {
fail('Migration nicht lesbar: ' . $file);
}
$db->beginTransaction();
try {
$db->exec($sql);
$statement = $db->prepare('insert into schema_migrations (version) values (:version)');
$statement->execute(['version' => $version]);
$db->commit();
} catch (Throwable $exception) {
$db->rollBack();
fail('Migration ' . $version . ' fehlgeschlagen: ' . $exception->getMessage());
}
out(' angewendet: ' . $version);
$ran++;
}
out($ran === 0 ? 'Das Schema ist aktuell.' : $ran . ' Migration(en) angewendet.');
break;
case 'user:create':
/** @var UserRepository $users */
$users = $container->get(UserRepository::class);
$username = ask('Benutzername: ');
$displayName = ask('Anzeigename: ', $username);
$roleInput = ask('Rolle [admin|inhaber|buero] (admin): ', 'admin');
$role = Role::tryFrom($roleInput) ?? fail('Unbekannte Rolle: ' . $roleInput);
if ($username === '') {
fail('Der Benutzername darf nicht leer sein.');
}
if ($users->usernameTaken($username)) {
fail('Dieser Benutzername ist bereits vergeben.');
}
$password = readSecret('Passwort: ');
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
fail($problem);
}
if ($password !== readSecret('Passwort wiederholen: ')) {
fail('Die Passwörter stimmen nicht überein.');
}
$user = $users->create($username, $displayName, $role, AuthService::hash($password));
$users->audit(null, 'CLI', 'user.create', $user->id, $user->username, ['role' => $role->value]);
out('Angelegt: ' . $user->username . ' (' . $user->role->label() . ')');
break;
case 'user:list':
/** @var UserRepository $users */
$users = $container->get(UserRepository::class);
$rows = $users->all();
if ($rows === []) {
out('Es ist noch kein Benutzer angelegt. Anlegen mit: php bin/ekdos user:create');
break;
}
out(sprintf('%-22s %-24s %-14s %-8s %s', 'BENUTZER', 'NAME', 'ROLLE', 'AKTIV', 'LETZTE ANMELDUNG'));
foreach ($rows as $user) {
out(sprintf(
'%-22s %-24s %-14s %-8s %s',
$user->username,
$user->displayName,
$user->role->value,
$user->isActive ? 'ja' : 'nein',
$user->lastLoginAt ?? '',
));
}
break;
case 'user:password':
/** @var UserRepository $users */
$users = $container->get(UserRepository::class);
$username = $argv[2] ?? fail('Aufruf: php bin/ekdos user:password <benutzername>');
$user = $users->findByUsernameWithHash($username) ?? fail('Unbekannter Benutzer: ' . $username);
$password = readSecret('Neues Passwort für ' . $user->username . ': ');
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
fail($problem);
}
$users->update($user->id, ['password_hash' => AuthService::hash($password)]);
$container->get(\Ekdos\Auth\SessionStore::class)->destroyAllFor($user->id);
$users->audit(null, 'CLI', 'user.password', $user->id, $user->username);
out('Passwort geändert. Offene Sitzungen dieses Benutzers wurden beendet.');
break;
case 'cache:flush':
/** @var Cache $cache */
$cache = $container->get(Cache::class);
out($cache->flushAll() . ' zwischengespeicherte n8n-Antworten verworfen.');
break;
case 'check':
/** @var Config $config */
$config = $container->get(Config::class);
$problems = 0;
try {
$container->get(PDO::class)->query('select 1');
out(' Postgres erreichbar');
} catch (Throwable $exception) {
out(' Postgres FEHLER: ' . $exception->getMessage());
$problems++;
}
try {
$container->get(Redis::class)->ping();
out(' Redis erreichbar');
} catch (Throwable $exception) {
out(' Redis FEHLER: ' . $exception->getMessage());
$problems++;
}
/** @var Endpoints $endpoints */
$endpoints = $container->get(Endpoints::class);
/** @var Client $n8n */
$n8n = $container->get(Client::class);
$reply = $n8n->get($endpoints->openTickets());
if ($reply->status === 0) {
out(' n8n FEHLER: nicht erreichbar unter ' . $endpoints->openTickets());
$problems++;
} else {
out(' n8n HTTP ' . $reply->status . ' von ' . $endpoints->openTickets());
}
foreach (['N8N_CUSTOMER_KEY', 'N8N_INVOICE_SYNC_SECRET'] as $secret) {
if (!$config->has($secret)) {
out(' Hinweis ' . $secret . ' ist nicht gesetzt; betroffene Funktionen antworten mit 503.');
}
}
exit($problems === 0 ? 0 : 1);
default:
out('EK-DOS-WEB Wartungs-CLI');
out();
out(' php bin/ekdos migrate Schema anlegen oder fortschreiben');
out(' php bin/ekdos user:create Benutzer anlegen');
out(' php bin/ekdos user:list Benutzer auflisten');
out(' php bin/ekdos user:password <name> Passwort zurücksetzen');
out(' php bin/ekdos cache:flush n8n-Cache leeren');
out(' php bin/ekdos check Postgres, Redis und n8n prüfen');
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "elektro-krueger/ekdos-backend",
"description": "EK-DOS-WEB Backend: BFF-Cookie-Sitzung, Benutzerverwaltung und zwischengespeicherte n8n-Weiterleitung.",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.5",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_pgsql": "*",
"ext-redis": "*",
"guzzlehttp/guzzle": "^7.9",
"php-di/php-di": "^7.0",
"slim/psr7": "^1.7",
"slim/slim": "^4.14",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": {
"Ekdos\\": "src/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
},
"scripts": {
"migrate": "php bin/ekdos migrate",
"serve": "php -S 127.0.0.1:8080 -t public"
}
}
+34
View File
@@ -0,0 +1,34 @@
-- EK-DOS-WEB: Benutzer, Rollen und Protokoll.
-- Sitzungen und n8n-Antworten liegen in Redis, nicht hier.
create extension if not exists pgcrypto;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
username text not null,
display_name text not null,
role text not null default 'buero',
password_hash text not null,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
last_login_at timestamptz,
constraint users_role_check check (role in ('admin', 'inhaber', 'buero'))
);
-- Anmeldename ohne Ruecksicht auf Gross-/Kleinschreibung eindeutig.
create unique index if not exists users_username_key on users (lower(username));
create index if not exists users_active_idx on users (is_active) where is_active;
create table if not exists user_audit (
id bigserial primary key,
actor_id uuid references users (id) on delete set null,
actor_name text not null,
action text not null,
subject_id uuid,
subject text not null default '',
detail jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists user_audit_created_at_idx on user_audit (created_at desc);
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* Einziger Einstiegspunkt des Backends.
*
* nginx reicht ausschliesslich /api/ hierher weiter; alles andere ist die
* statisch gebaute Oberflaeche und wird direkt von nginx ausgeliefert.
*/
use Ekdos\Bootstrap\Container;
use Ekdos\Bootstrap\Routes;
use Ekdos\Http\Middleware\ErrorHandler;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\Support\Config;
use Slim\Factory\AppFactory;
require __DIR__ . '/../vendor/autoload.php';
$projectRoot = dirname(__DIR__);
$container = Container::build($projectRoot);
/** @var Config $config */
$config = $container->get(Config::class);
// Ausnahmen gehen in den Fehlerkanal von php-fpm, niemals in die Antwort.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
AppFactory::setContainer($container);
$app = AppFactory::create();
Routes::register($app);
// Reihenfolge: zuletzt hinzugefuegt laeuft zuerst.
// ErrorHandler -> Session -> Routing -> Body-Parsing -> Route.
$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
$app->add($container->get(SessionMiddleware::class));
$app->add(new ErrorHandler($config->bool('APP_DEBUG', false)));
$app->run();
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\Support\Json;
use Ekdos\Users\Permission;
use Ekdos\Users\UserRepository;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class AuthController
{
public function __construct(
private AuthService $auth,
private SessionCookie $cookie,
private UserRepository $users,
) {}
public function login(Request $request, Response $response): Response
{
$body = $request->getParsedBody();
$body = is_array($body) ? $body : Json::decode((string) $request->getBody());
$result = $this->auth->login(
username: (string) ($body['username'] ?? $body['account'] ?? ''),
password: (string) ($body['password'] ?? ''),
clientIp: self::clientIp($request),
);
if ($result['ok'] === false) {
return Json::error($response, $result['error'], $result['status']);
}
return $this->cookie->attach(Json::write($response, ['user' => self::describe($result['session'])]), $result['session']->id);
}
/**
* Die Auth-Sonde der Oberflaeche.
*
* Bewusst ohne RequireAuth: die SPA fragt hier vor jeder Anzeige an und
* entscheidet anhand von 200 oder 401, ob sie die Anmeldemaske zeigt.
*/
public function me(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::write($response, ['user' => null], 401);
}
return Json::write($response, ['user' => self::describe($session)]);
}
/**
* Haelt die Sitzung am Leben, solange jemand am Bildschirm arbeitet.
* Die Oberflaeche ruft das alle fuenf Minuten auf.
*/
public function refresh(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::write($response, ['user' => null], 401);
}
return Json::write($response, ['user' => self::describe($session)]);
}
public function logout(Request $request, Response $response): Response
{
$this->auth->logout($this->cookie->read($request));
return $this->cookie->clear(Json::write($response, ['ok' => true]));
}
/** Eigenes Passwort aendern. Erlaubt jedem angemeldeten Benutzer. */
public function changeOwnPassword(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::error($response, 'Bitte erneut anmelden.', 401);
}
$body = $request->getParsedBody();
$body = is_array($body) ? $body : Json::decode((string) $request->getBody());
$current = (string) ($body['currentPassword'] ?? '');
$next = (string) ($body['newPassword'] ?? '');
$user = $this->users->findByUsernameWithHash($session->username);
if ($user === null || !password_verify($current, $user->passwordHash)) {
return Json::error($response, 'Das aktuelle Passwort ist nicht korrekt.', 403);
}
if (($problem = AuthService::rejectWeakPassword($next)) !== null) {
return Json::error($response, $problem, 400);
}
$this->users->update($user->id, ['password_hash' => AuthService::hash($next)]);
$this->users->audit($user->id, $user->displayName, 'password.self', $user->id, $user->username);
// Alle anderen Sitzungen dieses Benutzers verfallen; die eigene bleibt bestehen.
return Json::write($response, ['ok' => true]);
}
/** @return array<string, mixed> */
private static function describe(Session $session): array
{
return [
'id' => $session->userId,
'username' => $session->username,
'name' => $session->displayName,
'role' => $session->role->value,
'roleLabel' => $session->role->label(),
'permissions' => $session->role->permissions(),
'canManageUsers' => $session->can(Permission::UsersManage),
];
}
/**
* Hinter nginx steht die echte Adresse in X-Forwarded-For. Es wird nur der
* erste Eintrag verwendet und nur, wenn der Header ueberhaupt gesetzt ist --
* der Origin ist ausschliesslich ueber den Proxy erreichbar.
*/
private static function clientIp(Request $request): string
{
$forwarded = $request->getHeaderLine('X-Forwarded-For');
if ($forwarded !== '') {
return trim(explode(',', $forwarded)[0]);
}
$server = $request->getServerParams();
return is_string($server['REMOTE_ADDR'] ?? null) ? $server['REMOTE_ADDR'] : 'unbekannt';
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\UserRepository;
use Redis;
/**
* Anmeldung gegen die eigene Benutzertabelle.
*
* Die Bremse haengt an Benutzername und Quell-IP gemeinsam, damit weder ein
* verteilter Angriff auf ein Konto noch ein einzelner Client durchprobieren kann.
*/
final readonly class AuthService
{
public function __construct(
private UserRepository $users,
private SessionStore $sessions,
private Redis $redis,
private string $prefix,
private int $maxAttempts,
private int $window,
) {}
/**
* @return array{ok: true, session: Session}|array{ok: false, error: string, status: int}
*/
public function login(string $username, string $password, string $clientIp): array
{
$username = trim($username);
if ($username === '' || $password === '') {
return ['ok' => false, 'error' => 'Benutzername und Passwort sind erforderlich.', 'status' => 400];
}
if ($this->isThrottled($username, $clientIp)) {
return ['ok' => false, 'error' => 'Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.', 'status' => 429];
}
$user = $this->users->findByUsernameWithHash($username);
// Auch bei unbekanntem Benutzer wird gehasht, damit die Antwortzeit nichts verraet.
$hash = $user?->passwordHash ?? '$argon2id$v=19$m=65536,t=4,p=1$aaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
$valid = password_verify($password, $hash);
if ($user === null || !$valid) {
$this->recordFailure($username, $clientIp);
return ['ok' => false, 'error' => 'Benutzer oder Passwort ist nicht korrekt.', 'status' => 401];
}
if (!$user->isActive) {
$this->recordFailure($username, $clientIp);
return ['ok' => false, 'error' => 'Dieses Konto ist deaktiviert.', 'status' => 403];
}
$this->clearFailures($username, $clientIp);
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
$this->users->update($user->id, ['password_hash' => self::hash($password)]);
}
$this->users->touchLogin($user->id);
return ['ok' => true, 'session' => $this->sessions->create($user)];
}
public function logout(string $sessionId): void
{
if ($sessionId !== '') {
$this->sessions->destroy($sessionId);
}
}
public static function hash(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID);
}
/** Mindestanforderung an ein Passwort. Bewusst schlicht: Laenge schlaegt Zeichenklassen. */
public static function rejectWeakPassword(string $password): ?string
{
if (mb_strlen($password) < 12) {
return 'Das Passwort muss mindestens 12 Zeichen lang sein.';
}
if (mb_strlen($password) > 200) {
return 'Das Passwort darf höchstens 200 Zeichen lang sein.';
}
return null;
}
private function isThrottled(string $username, string $clientIp): bool
{
return (int) ($this->redis->get($this->throttleKey($username, $clientIp)) ?: 0) >= $this->maxAttempts;
}
private function recordFailure(string $username, string $clientIp): void
{
$key = $this->throttleKey($username, $clientIp);
if ((int) $this->redis->incr($key) === 1) {
$this->redis->expire($key, $this->window);
}
}
private function clearFailures(string $username, string $clientIp): void
{
$this->redis->del($this->throttleKey($username, $clientIp));
}
private function throttleKey(string $username, string $clientIp): string
{
return $this->prefix . 'login:' . sha1(strtolower($username) . '|' . $clientIp);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\Role;
/**
* Der serverseitige Teil der Sitzung. Der Browser kennt davon nichts ausser
* der undurchsichtigen Kennung im Cookie.
*/
final readonly class Session
{
public function __construct(
public string $id,
public string $userId,
public string $username,
public string $displayName,
public Role $role,
public int $issuedAt,
public int $lastSeenAt,
) {}
public static function fromArray(string $id, array $data): ?self
{
$role = Role::tryFrom((string) ($data['role'] ?? ''));
if ($role === null || !isset($data['userId'], $data['username'])) {
return null;
}
return new self(
id: $id,
userId: (string) $data['userId'],
username: (string) $data['username'],
displayName: (string) ($data['displayName'] ?? $data['username']),
role: $role,
issuedAt: (int) ($data['issuedAt'] ?? 0),
lastSeenAt: (int) ($data['lastSeenAt'] ?? 0),
);
}
public function toArray(): array
{
return [
'userId' => $this->userId,
'username' => $this->username,
'displayName' => $this->displayName,
'role' => $this->role->value,
'issuedAt' => $this->issuedAt,
'lastSeenAt' => $this->lastSeenAt,
];
}
public function can(string $permission): bool
{
return $this->role->allows($permission);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Das Cookie traegt nur die Sitzungskennung.
*
* Ohne Max-Age bleibt es ein reines Sitzungscookie: schliesst jemand den Browser
* vollstaendig, ist beim naechsten Aufruf wieder eine Anmeldung noetig. Das war
* schon in der Next.js-Fassung so und bleibt bewusst erhalten.
*/
final readonly class SessionCookie
{
public function __construct(
private string $name,
private bool $secure,
) {}
public function read(Request $request): string
{
$value = $request->getCookieParams()[$this->name] ?? '';
return is_string($value) ? $value : '';
}
public function attach(Response $response, string $id): Response
{
return $response->withAddedHeader('Set-Cookie', $this->build($id, null));
}
public function clear(Response $response): Response
{
return $response->withAddedHeader('Set-Cookie', $this->build('', 0));
}
private function build(string $value, ?int $maxAge): string
{
$parts = [
$this->name . '=' . $value,
'Path=/',
'HttpOnly',
'SameSite=Strict',
];
if ($this->secure) {
$parts[] = 'Secure';
}
if ($maxAge !== null) {
$parts[] = 'Max-Age=' . $maxAge;
}
return implode('; ', $parts);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\User;
use Redis;
/**
* Serverseitiger Sitzungsspeicher (BFF-Ticket-Store) auf Redis.
*
* Der Browser bekommt ausschliesslich eine zufaellige, undurchsichtige Kennung.
* Rollen, Rechte und Anzeigename bleiben auf dem Server: das Cookie waechst
* nicht mit den Claims mit und ein Reverse Proxy hat nie zu grosse Header.
*
* Schluessel:
* <prefix>sess:<id> Sitzungsdaten, TTL = Leerlauffenster
* <prefix>sess:user:<uid> Menge aller Sitzungen eines Benutzers, fuer Zwangsabmeldung
*/
final readonly class SessionStore
{
public function __construct(
private Redis $redis,
private string $prefix,
private int $ttl,
) {}
public function create(User $user): Session
{
$now = time();
$session = new Session(
id: self::newId(),
userId: $user->id,
username: $user->username,
displayName: $user->displayName,
role: $user->role,
issuedAt: $now,
lastSeenAt: $now,
);
$this->redis->setex($this->key($session->id), $this->ttl, json_encode($session->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->redis->sAdd($this->userKey($user->id), $session->id);
// Der Index darf den laengsten moeglichen Sitzungslauf ueberdauern, nicht laenger.
$this->redis->expire($this->userKey($user->id), $this->ttl * 24);
return $session;
}
public function read(string $id): ?Session
{
if (!self::looksLikeId($id)) {
return null;
}
$raw = $this->redis->get($this->key($id));
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? Session::fromArray($id, $decoded) : null;
}
/**
* Gleitendes Leerlauffenster: jede authentifizierte Anfrage schiebt die TTL nach vorn.
* Der Rumpf wird nur einmal pro Minute neu geschrieben, sonst reicht ein EXPIRE.
*/
public function touch(Session $session): void
{
$now = time();
if ($now - $session->lastSeenAt < 60) {
$this->redis->expire($this->key($session->id), $this->ttl);
return;
}
$refreshed = new Session(
id: $session->id,
userId: $session->userId,
username: $session->username,
displayName: $session->displayName,
role: $session->role,
issuedAt: $session->issuedAt,
lastSeenAt: $now,
);
$this->redis->setex($this->key($session->id), $this->ttl, json_encode($refreshed->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
public function destroy(string $id): void
{
$session = $this->read($id);
if ($session !== null) {
$this->redis->sRem($this->userKey($session->userId), $id);
}
$this->redis->del($this->key($id));
}
/**
* Meldet einen Benutzer ueberall ab. Wird nach Rollenwechsel, Deaktivierung,
* Passwortwechsel und Loeschung aufgerufen, damit eine laufende Sitzung
* keine Rechte behaelt, die der Benutzer nicht mehr hat.
*/
public function destroyAllFor(string $userId): int
{
$ids = $this->redis->sMembers($this->userKey($userId));
if (!is_array($ids) || $ids === []) {
$this->redis->del($this->userKey($userId));
return 0;
}
$this->redis->del(array_map(fn (string $id): string => $this->key($id), $ids));
$this->redis->del($this->userKey($userId));
return count($ids);
}
private static function newId(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
private static function looksLikeId(string $id): bool
{
return $id !== '' && strlen($id) <= 64 && preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1;
}
private function key(string $id): string
{
return $this->prefix . 'sess:' . $id;
}
private function userKey(string $userId): string
{
return $this->prefix . 'sess:user:' . $userId;
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Ekdos\Bootstrap;
use DI\ContainerBuilder;
use Ekdos\Auth\AuthService;
use Ekdos\Auth\SessionCookie;
use Ekdos\Auth\SessionStore;
use Ekdos\N8n\Cache;
use Ekdos\N8n\Client;
use Ekdos\N8n\Endpoints;
use Ekdos\Support\Config;
use Ekdos\Users\UserRepository;
use GuzzleHttp\Client as Guzzle;
use PDO;
use Psr\Container\ContainerInterface;
use Redis;
use function DI\autowire;
use function DI\factory;
use function DI\get;
/**
* Der Kompositionswurzel. Alles, was einen Zustand oder eine Verbindung haelt,
* wird hier genau einmal beschrieben.
*/
final class Container
{
public static function build(string $projectRoot): ContainerInterface
{
$builder = new ContainerBuilder();
$config = self::loadConfig($projectRoot);
if (!$config->bool('APP_DEBUG', false)) {
$builder->enableCompilation($projectRoot . '/var/cache');
}
$builder->addDefinitions([
Config::class => $config,
// ── PostgreSQL ────────────────────────────────────────────────
PDO::class => factory(static function (Config $config): PDO {
return new PDO(
$config->string('DB_DSN', 'pgsql:host=127.0.0.1;port=5432;dbname=ekdos'),
$config->string('DB_USER', 'ekdos'),
$config->string('DB_PASSWORD'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
);
}),
// ── Redis: Sitzungen und n8n-Cache ────────────────────────────
Redis::class => factory(static function (Config $config): Redis {
$redis = new Redis();
$redis->connect($config->string('REDIS_HOST', '127.0.0.1'), $config->int('REDIS_PORT', 6379), 2.0);
if ($config->has('REDIS_PASSWORD')) {
$redis->auth($config->string('REDIS_PASSWORD'));
}
$redis->select($config->int('REDIS_DB', 0));
return $redis;
}),
SessionStore::class => factory(static function (Redis $redis, Config $config): SessionStore {
return new SessionStore($redis, $config->string('REDIS_PREFIX', 'ekdos:'), $config->int('SESSION_TTL', 3600));
}),
SessionCookie::class => factory(static function (Config $config): SessionCookie {
return new SessionCookie($config->string('SESSION_COOKIE', 'ekdos_session'), $config->bool('SESSION_COOKIE_SECURE', true));
}),
AuthService::class => factory(static function (UserRepository $users, SessionStore $sessions, Redis $redis, Config $config): AuthService {
return new AuthService(
users: $users,
sessions: $sessions,
redis: $redis,
prefix: $config->string('REDIS_PREFIX', 'ekdos:'),
maxAttempts: $config->int('LOGIN_MAX_ATTEMPTS', 10),
window: $config->int('LOGIN_WINDOW', 900),
);
}),
Cache::class => factory(static function (Redis $redis, Config $config): Cache {
return new Cache($redis, $config->string('REDIS_PREFIX', 'ekdos:'));
}),
// ── n8n ───────────────────────────────────────────────────────
Endpoints::class => factory(static fn (Config $config): Endpoints => Endpoints::fromConfig($config)),
Guzzle::class => factory(static function (Config $config): Guzzle {
return new Guzzle([
'timeout' => $config->int('N8N_TIMEOUT', 15),
'connect_timeout' => $config->int('N8N_CONNECT_TIMEOUT', 5),
// n8n antwortet gelegentlich mit einer Weiterleitung auf sich selbst.
'allow_redirects' => ['max' => 3],
'headers' => ['User-Agent' => 'EK-DOS-WEB/3.0'],
]);
}),
Client::class => autowire()->constructorParameter('http', get(Guzzle::class)),
UserRepository::class => autowire(),
]);
return $builder->build();
}
/** Liest .env, faellt aber auf echte Umgebungsvariablen zurueck (Container, systemd). */
public static function loadConfig(string $projectRoot): Config
{
$values = getenv();
if (is_readable($projectRoot . '/.env')) {
$dotenv = \Dotenv\Dotenv::createArrayBacked($projectRoot);
$values = $dotenv->load() + $values;
}
return Config::fromEnvironment($values);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Ekdos\Bootstrap;
use Ekdos\Auth\AuthController;
use Ekdos\Http\Middleware\RequireAuth;
use Ekdos\Http\Middleware\RequirePermission;
use Ekdos\Relay\CustomerInvoicesController;
use Ekdos\Relay\CustomersController;
use Ekdos\Relay\HoursController;
use Ekdos\Relay\InternalTasksController;
use Ekdos\Relay\InvoicesController;
use Ekdos\Relay\OffersController;
use Ekdos\Relay\OnlinePurchasesController;
use Ekdos\Relay\TicketsController;
use Ekdos\Support\Json;
use Ekdos\Users\Permission;
use Ekdos\Users\UserController;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
/**
* Alle Routen der Anwendung.
*
* Anonym erreichbar sind nur die Gesundheitspruefung, die Anmeldung, die
* Auth-Sonde /api/auth/me und der Cache-Hinweis von n8n. Alles andere haengt
* hinter RequireAuth, rechtegebundene Aktionen zusaetzlich hinter RequirePermission.
*/
final class Routes
{
public static function register(App $app): void
{
$app->group('/api', static function (RouteCollectorProxy $api): void {
// ── Anonym ────────────────────────────────────────────────────
// Die Oberflaeche prueft vor der Anmeldung, ob das Backend lebt.
$api->get('/health', static fn (Request $r, Response $w): Response => Json::write($w, ['ok' => true, 'service' => 'ek-dos-web', 'time' => gmdate('c')]));
$api->post('/auth/login', [AuthController::class, 'login']);
$api->get('/auth/me', [AuthController::class, 'me']);
$api->post('/auth/logout', [AuthController::class, 'logout']);
// n8n meldet hierueber eine Ticketerstellung. Traegt entweder eine
// Sitzung oder das geteilte Geheimnis; die Pruefung liegt im Controller.
$api->post('/tickets/refresh', [TicketsController::class, 'refresh']);
// ── Angemeldet ────────────────────────────────────────────────
$api->group('', static function (RouteCollectorProxy $secure): void {
$secure->post('/auth/refresh', [AuthController::class, 'refresh']);
$secure->post('/auth/password', [AuthController::class, 'changeOwnPassword']);
// Tickets
$secure->get('/tickets', [TicketsController::class, 'index']);
$secure->get('/tickets/digitale-akte', [TicketsController::class, 'digitalFile']);
$secure->get('/tickets/servicebericht', [TicketsController::class, 'serviceReport']);
$secure->post('/tickets/consultation', [TicketsController::class, 'consultation'])
->add(new RequirePermission(Permission::InvoicesProcess));
// Angebote
$secure->get('/offers', [OffersController::class, 'index']);
$secure->post('/offers', [OffersController::class, 'update']);
$secure->delete('/offers', [OffersController::class, 'delete'])
->add(new RequirePermission(Permission::OffersDelete));
// Kundenstamm
$secure->get('/customers', [CustomersController::class, 'index']);
$secure->post('/customers', [CustomersController::class, 'create']);
$secure->put('/customers', [CustomersController::class, 'update']);
$secure->delete('/customers', [CustomersController::class, 'delete'])
->add(new RequirePermission(Permission::CustomersDelete));
$secure->get('/customers/digitale-akte', [CustomersController::class, 'digitalFile']);
// Rechnungen eines Kunden und die Gesamtuebersicht
$secure->get('/customer-invoices', [CustomerInvoicesController::class, 'index']);
$secure->post('/customer-invoices', [CustomerInvoicesController::class, 'assign']);
$secure->get('/customer-invoices/pdf', [CustomerInvoicesController::class, 'pdf']);
$secure->post('/customer-invoices/sync', [CustomerInvoicesController::class, 'sync']);
// Rechnungsablauf: anfertigen, pruefen, versenden
$secure->get('/invoices-review', [InvoicesController::class, 'reviewList']);
$secure->group('', static function (RouteCollectorProxy $invoices): void {
$invoices->get('/invoices-create', [InvoicesController::class, 'createList']);
$invoices->put('/invoices-create', [InvoicesController::class, 'completeCreateTask']);
$invoices->get('/invoices-create/digitale-akte', [InvoicesController::class, 'digitalFile']);
$invoices->get('/invoices-send', [InvoicesController::class, 'sendList']);
$invoices->post('/invoices-send', [InvoicesController::class, 'confirmSent']);
})->add(new RequirePermission(Permission::InvoicesProcess));
// Interne Aufgaben
$secure->get('/internal-tasks', [InternalTasksController::class, 'index']);
$secure->post('/internal-tasks', [InternalTasksController::class, 'create']);
$secure->put('/internal-tasks', [InternalTasksController::class, 'complete']);
$secure->patch('/internal-tasks', [InternalTasksController::class, 'edit']);
$secure->delete('/internal-tasks', [InternalTasksController::class, 'delete'])
->add(new RequirePermission(Permission::TasksDelete));
// Online-Kaeufe
$secure->get('/online-purchases', [OnlinePurchasesController::class, 'index']);
$secure->post('/online-purchases', [OnlinePurchasesController::class, 'create'])
->add(new RequirePermission(Permission::PurchasesCreate));
$secure->put('/online-purchases', [OnlinePurchasesController::class, 'complete'])
->add(new RequirePermission(Permission::PurchasesComplete));
$secure->delete('/online-purchases', [OnlinePurchasesController::class, 'delete'])
->add(new RequirePermission(Permission::PurchasesDelete));
// Stundennachweise
$secure->get('/hours', [HoursController::class, 'index']);
// Benutzerverwaltung
$secure->group('/users', static function (RouteCollectorProxy $users): void {
$users->get('', [UserController::class, 'index']);
$users->post('', [UserController::class, 'create']);
$users->get('/audit', [UserController::class, 'audit']);
$users->patch('/{id}', [UserController::class, 'update']);
$users->delete('/{id}', [UserController::class, 'delete']);
})->add(new RequirePermission(Permission::UsersManage));
})->add(new RequireAuth());
});
// Alles unterhalb von /api, was es nicht gibt, antwortet als JSON.
// Statische Dateien beantwortet nginx, sie erreichen PHP nie.
$app->any('/api/{path:.*}', static fn (Request $r, Response $w): Response => Json::error($w, 'Diese Schnittstelle gibt es nicht.', 404));
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Ekdos\Http\Middleware;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Response as Psr7Response;
use Throwable;
/**
* Ausnahmen werden protokolliert, nicht erzaehlt.
*
* Es gibt bewusst kein Info-/Debug-Logging: in das Protokoll gehoert nur, was
* tatsaechlich schiefgegangen ist. error_log() landet im Fehlerkanal von php-fpm
* und damit dort, wo auch nginx hinschreibt.
*
* Soll spaeter Sentry dazukommen, ist genau eine Zeile noetig -- siehe unten.
*/
final readonly class ErrorHandler implements MiddlewareInterface
{
public function __construct(private bool $debug) {}
public function process(Request $request, Handler $handler): Response
{
try {
return $handler->handle($request);
} catch (HttpMethodNotAllowedException) {
return Json::error(new Psr7Response(), 'Diese Methode ist für diese Schnittstelle nicht vorgesehen.', 405);
} catch (HttpNotFoundException) {
// Kein Fehlerfall, sondern ein Tippfehler in der Adresse. Nicht protokollieren.
return Json::error(new Psr7Response(), 'Diese Schnittstelle gibt es nicht.', 404);
} catch (Throwable $exception) {
error_log(sprintf(
'[ekdos] %s %s -- %s: %s @ %s:%d',
$request->getMethod(),
(string) $request->getUri()->getPath(),
$exception::class,
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
));
// Sentry-Einstiegspunkt: \Sentry\captureException($exception);
$message = $this->debug
? $exception::class . ': ' . $exception->getMessage()
: 'Im Backend ist ein unerwarteter Fehler aufgetreten.';
return Json::error(new Psr7Response(), $message, 500);
}
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Ekdos\Http\Middleware;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Psr7\Response as Psr7Response;
/**
* Sauberes 401 statt einer Weiterleitung: die Oberflaeche liest den Status und
* zeigt daraufhin die Anmeldemaske. Es gibt hier nichts umzuleiten, weil der
* Anmeldedialog Teil der SPA ist.
*/
final class RequireAuth implements MiddlewareInterface
{
public function process(Request $request, Handler $handler): Response
{
if (SessionMiddleware::of($request) === null) {
return Json::error(new Psr7Response(), 'Bitte erneut anmelden.', 401);
}
return $handler->handle($request);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Ekdos\Http\Middleware;
use Ekdos\Support\Json;
use Ekdos\Users\Permission;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Psr7\Response as Psr7Response;
/**
* Ersetzt die frueheren Namensabfragen ("nur Sascha", "nur Svenja") durch ein Recht.
* Die Meldung nennt das fehlende Recht, damit im Buero klar ist, wer helfen kann.
*/
final readonly class RequirePermission implements MiddlewareInterface
{
public function __construct(private string $permission) {}
public function process(Request $request, Handler $handler): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::error(new Psr7Response(), 'Bitte erneut anmelden.', 401);
}
if (!$session->can($this->permission)) {
return Json::error(
new Psr7Response(),
'Dafür fehlt die Berechtigung "' . Permission::label($this->permission) . '".',
403,
);
}
return $handler->handle($request);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Ekdos\Http\Middleware;
use Ekdos\Auth\Session;
use Ekdos\Auth\SessionCookie;
use Ekdos\Auth\SessionStore;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
/**
* Loest das Cookie in eine Sitzung auf und haengt sie an die Anfrage.
* Sie weist nichts ab: das uebernehmen RequireAuth und RequirePermission.
*/
final readonly class SessionMiddleware implements MiddlewareInterface
{
public const string ATTRIBUTE = 'session';
public function __construct(
private SessionStore $sessions,
private SessionCookie $cookie,
) {}
public function process(Request $request, Handler $handler): Response
{
$id = $this->cookie->read($request);
$session = $id === '' ? null : $this->sessions->read($id);
if ($session !== null) {
$this->sessions->touch($session);
}
return $handler->handle($request->withAttribute(self::ATTRIBUTE, $session));
}
public static function of(Request $request): ?Session
{
$session = $request->getAttribute(self::ATTRIBUTE);
return $session instanceof Session ? $session : null;
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
use Redis;
/**
* Kurzlebiger Redis-Cache vor n8n.
*
* Das Buero laesst mehrere Ansichten gleichzeitig offen, und die Ticketliste
* aktualisiert sich alle 20 Sekunden von selbst. Ohne Cache landet jeder dieser
* Takte als eigener Aufruf bei n8n. Drei Mechanismen verhindern das:
*
* 1. Frischer Eintrag Innerhalb der TTL wird ohne Ruecksprache geantwortet.
*
* 2. Single-Flight Laeuft der Cache ab, waehrend drei Ansichten gleichzeitig
* fragen, holt genau eine die Daten. Die anderen warten
* kurz auf deren Ergebnis, statt parallel loszurennen.
*
* 3. Stale-on-Error Neben dem frischen Eintrag liegt eine deutlich laenger
* haltbare Kopie. Ist n8n nicht erreichbar, sieht das Buero
* die letzten bekannten Daten statt einer Fehlerseite.
*
* Schreibende Aufrufe verwerfen ihre Gruppe sofort (invalidate), damit eine
* Aenderung nicht bis zum Ablauf der TTL unsichtbar bleibt.
*
* Schluessel:
* <prefix>n8n:fresh:<hash> die kurzlebige Antwort
* <prefix>n8n:stale:<hash> die Rueckfallkopie
* <prefix>n8n:lock:<hash> Single-Flight-Sperre
* <prefix>n8n:group:<name> Menge aller Schluessel einer Gruppe
*/
final readonly class Cache
{
/** Wie lange ein wartender Aufrufer auf das Ergebnis des Single-Flight hofft. */
private const int WAIT_TOTAL_MS = 400;
private const int WAIT_STEP_MS = 25;
private const int LOCK_TTL_SECONDS = 15;
public function __construct(
private Redis $redis,
private string $prefix,
) {}
/**
* @param string $group Gruppe fuer die gezielte Verwerfung, z. B. "tickets".
* @param string $key Eindeutig fuer diese Abfrage inklusive Parameter.
* @param int $ttl Sekunden, die die Antwort als frisch gilt.
* @param int $staleTtl Sekunden, die die Rueckfallkopie vorgehalten wird.
* @param callable():Reply $fetch
*/
public function remember(string $group, string $key, int $ttl, int $staleTtl, callable $fetch): Reply
{
$hash = sha1($key);
if (($hit = $this->readEntry($this->freshKey($hash))) !== null) {
return $hit;
}
if (!$this->acquireLock($hash)) {
// Jemand anderes holt gerade. Kurz auf dessen Ergebnis warten.
if (($shared = $this->waitForFresh($hash)) !== null) {
return $shared;
}
// Warten hat nichts gebracht: lieber etwas Veraltetes als gar nichts.
if (($stale = $this->readEntry($this->staleKey($hash))) !== null) {
return $stale->asStale();
}
}
try {
$reply = $fetch();
if ($reply->ok()) {
$this->store($group, $hash, $reply, $ttl, $staleTtl);
return $reply;
}
// n8n antwortet, aber mit einem Fehler. Alte Daten schlagen eine Fehlerseite.
return $this->readEntry($this->staleKey($hash))?->asStale() ?? $reply;
} finally {
$this->releaseLock($hash);
}
}
/** Verwirft alle Eintraege der genannten Gruppen. Nach jedem schreibenden Aufruf. */
public function invalidate(string ...$groups): void
{
foreach ($groups as $group) {
$groupKey = $this->groupKey($group);
$members = $this->redis->sMembers($groupKey);
if (is_array($members) && $members !== []) {
$this->redis->del($members);
}
$this->redis->del($groupKey);
}
}
/** Leert alles, was dieser Cache angelegt hat. Nur fuer die Wartungs-CLI. */
public function flushAll(): int
{
$removed = 0;
$pattern = $this->prefix . 'n8n:*';
$cursor = null;
do {
$keys = $this->redis->scan($cursor, $pattern, 500);
if (is_array($keys) && $keys !== []) {
$removed += (int) $this->redis->del($keys);
}
} while ($cursor > 0);
return $removed;
}
private function store(string $group, string $hash, Reply $reply, int $ttl, int $staleTtl): void
{
$payload = json_encode(
['status' => $reply->status, 'body' => $reply->body, 'contentType' => $reply->contentType],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);
$this->redis->setex($this->freshKey($hash), max(1, $ttl), $payload);
$this->redis->setex($this->staleKey($hash), max($ttl, $staleTtl), $payload);
$this->redis->sAdd($this->groupKey($group), $this->freshKey($hash), $this->staleKey($hash));
$this->redis->expire($this->groupKey($group), max($ttl, $staleTtl) + 60);
}
private function readEntry(string $key): ?Reply
{
$raw = $this->redis->get($key);
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded) || !isset($decoded['status'], $decoded['body'])) {
return null;
}
return new Reply((int) $decoded['status'], (string) $decoded['body'], (string) ($decoded['contentType'] ?? ''));
}
private function waitForFresh(string $hash): ?Reply
{
$deadline = microtime(true) + self::WAIT_TOTAL_MS / 1000;
while (microtime(true) < $deadline) {
usleep(self::WAIT_STEP_MS * 1000);
if (($hit = $this->readEntry($this->freshKey($hash))) !== null) {
return $hit;
}
}
return null;
}
private function acquireLock(string $hash): bool
{
return (bool) $this->redis->set($this->lockKey($hash), '1', ['NX', 'EX' => self::LOCK_TTL_SECONDS]);
}
private function releaseLock(string $hash): void
{
$this->redis->del($this->lockKey($hash));
}
private function freshKey(string $hash): string
{
return $this->prefix . 'n8n:fresh:' . $hash;
}
private function staleKey(string $hash): string
{
return $this->prefix . 'n8n:stale:' . $hash;
}
private function lockKey(string $hash): string
{
return $this->prefix . 'n8n:lock:' . $hash;
}
private function groupKey(string $group): string
{
return $this->prefix . 'n8n:group:' . $group;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
/**
* Wie lange welcher Bereich zwischengespeichert wird.
*
* Die frische TTL orientiert sich am Aktualisierungstakt der Oberflaeche: die
* Ticketliste laedt alle 20 Sekunden nach, 15 Sekunden Cache fangen also beide
* offenen Arbeitsplaetze ab, ohne dass jemand veraltete Daten sieht.
*
* Die Rueckfall-TTL ist grosszuegig. Sie greift nur, wenn n8n nicht erreichbar
* ist -- dann sind zehn Minuten alte Tickets deutlich besser als eine Fehlermeldung.
*
* @phpstan-type Policy array{0: string, 1: int, 2: int}
*/
final class CachePolicy
{
// Gruppe frisch Rueckfall
public const array Tickets = ['tickets', 15, 600];
public const array Offers = ['offers', 30, 600];
public const array Customers = ['customers', 120, 1800];
public const array Tasks = ['tasks', 20, 600];
public const array Invoices = ['invoices', 30, 600];
public const array InvoicesCreate = ['invoices-create', 30, 600];
public const array InvoicesReview = ['invoices-review', 30, 600];
public const array InvoicesSend = ['invoices-send', 30, 600];
public const array Purchases = ['purchases', 30, 600];
/** Der Stundennachweis ist ein schwerer Report und aendert sich selten. */
public const array Hours = ['hours', 300, 3600];
/** Alle Gruppen, fuer die Wartungs-CLI und den Sammelaufruf nach dem Abgleich. */
public const array ALL_GROUPS = [
'tickets', 'offers', 'customers', 'tasks',
'invoices', 'invoices-create', 'invoices-review', 'invoices-send',
'purchases', 'hours',
];
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Exception\GuzzleException;
/**
* Duenner HTTP-Zugang zu n8n.
*
* Wirft bei einem HTTP-Fehlerstatus nicht: der Aufrufer entscheidet, was ein
* 404 oder 502 aus n8n fuer die Oberflaeche bedeutet. Nur ein echter
* Verbindungsfehler ergibt Status 0.
*/
final readonly class Client
{
public function __construct(private Guzzle $http) {}
public function get(string $url, array $headers = []): Reply
{
return $this->send('GET', $url, $headers, null);
}
public function post(string $url, ?array $json = null, array $headers = []): Reply
{
return $this->send('POST', $url, $headers, $json);
}
public function put(string $url, ?array $json = null, array $headers = []): Reply
{
return $this->send('PUT', $url, $headers, $json);
}
public function delete(string $url, ?array $json = null, array $headers = []): Reply
{
return $this->send('DELETE', $url, $headers, $json);
}
private function send(string $method, string $url, array $headers, ?array $json): Reply
{
$options = ['headers' => $headers + ['Accept' => 'application/json'], 'http_errors' => false];
if ($json !== null) {
$options['json'] = $json;
}
try {
$response = $this->http->request($method, $url, $options);
return new Reply(
status: $response->getStatusCode(),
body: (string) $response->getBody(),
contentType: $response->getHeaderLine('Content-Type'),
);
} catch (GuzzleException) {
// n8n nicht erreichbar. Kein Stacktrace ins Protokoll: der Aufrufer
// meldet das als 502 und faellt, wo moeglich, auf den Cache zurueck.
return new Reply(status: 0, body: '', contentType: '');
}
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
use Ekdos\Support\Config;
/**
* Alle n8n-Adressen an einer Stelle.
*
* Zwei Wege, bewusst getrennt:
*
* webhook() Der n8n-Dienst auf Port 5678. Er ist ausschliesslich ueber den
* CNAME n8n.elektro-krueger.eu erreichbar, deshalb laeuft jeder
* ek-dos-web-Webhook darueber.
*
* internal() Jeder weitere n8n-Dienst, der auf einem anderen Port lauscht,
* wird ueber die RFC1918-Adresse angesprochen. Diese Dienste sind
* nicht nach aussen veroeffentlicht.
*/
final readonly class Endpoints
{
private const string PREFIX = '/webhook/ek-dos-web';
public function __construct(
private string $publicBase,
private string $internalHost,
) {}
public static function fromConfig(Config $config): self
{
return new self(
publicBase: rtrim($config->string('N8N_PUBLIC_BASE', 'https://n8n.elektro-krueger.eu'), '/'),
internalHost: $config->string('N8N_INTERNAL_HOST', '10.0.11.131'),
);
}
/** Webhook am Hauptdienst (Port 5678, nur ueber den CNAME erreichbar). */
public function webhook(string $path = '', array $query = []): string
{
return $this->publicBase . self::PREFIX . $path . self::query($query);
}
/** Beliebiger weiterer n8n-Dienst im internen Netz. */
public function internal(int $port, string $path, array $query = []): string
{
return 'http://' . $this->internalHost . ':' . $port . $path . self::query($query);
}
// ── Tickets ───────────────────────────────────────────────────────────
public function openTickets(): string
{
return $this->webhook('/offene-tickets');
}
public function ticketConsultation(): string
{
return $this->webhook('/offene-tickets/kundenruecksprache');
}
public function ticketDigitalFile(string $ticket): string
{
return $this->webhook('/tickets/digitale-akte', ['ticket' => $ticket]);
}
public function ticketServiceReport(string $ticket): string
{
return $this->webhook('/tickets/servicebericht', ['ticket' => $ticket]);
}
// ── Angebote ──────────────────────────────────────────────────────────
public function offers(string $action = ''): string
{
return $this->webhook('/angebote' . $action);
}
// ── Kundenstamm ───────────────────────────────────────────────────────
public function customers(): string
{
return $this->webhook('/kundenstamm');
}
public function customerDigitalFile(string $customer): string
{
return $this->webhook('/kundenstamm/digitale-akte', ['customer' => $customer]);
}
// ── Rechnungen ────────────────────────────────────────────────────────
public function invoices(array $query = []): string
{
return $this->webhook('/rechnungen', $query);
}
public function allInvoices(): string
{
return $this->webhook('/rechnungen/alle');
}
public function assignInvoice(): string
{
return $this->webhook('/rechnungen/zuordnen');
}
public function invoicePdf(string $path): string
{
return $this->webhook('/rechnungen/pdf', ['path' => $path]);
}
public function invoiceSync(): string
{
return $this->webhook('/rechnungen/sync');
}
public function invoicesCreate(): string
{
return $this->webhook('/rechnungen-anfertigen');
}
public function invoicesCreateComplete(): string
{
return $this->webhook('/rechnungen-anfertigen/erledigt');
}
public function invoicesReview(): string
{
return $this->webhook('/rechnungen-pruefen');
}
public function invoicesSend(): string
{
return $this->webhook('/rechnungen-versenden');
}
public function digitalFile(string $taskId): string
{
return $this->webhook('/digitale-akte', ['taskId' => $taskId]);
}
// ── Interne Aufgaben ──────────────────────────────────────────────────
public function internalTasks(string $action = ''): string
{
return $this->webhook('/interne-aufgaben' . $action);
}
// ── Online-Kaeufe ─────────────────────────────────────────────────────
public function onlinePurchases(string $action = ''): string
{
return $this->webhook('/online-kaeufe' . $action);
}
// ── Stundennachweise ──────────────────────────────────────────────────
public function hours(): string
{
return $this->webhook('/stundennachweise');
}
private static function query(array $query): string
{
$filtered = array_filter($query, static fn (mixed $value): bool => $value !== null && $value !== '');
return $filtered === [] ? '' : '?' . http_build_query($filtered);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
use Ekdos\Support\Json;
/** Eine Antwort von n8n, roh und unbewertet. */
final readonly class Reply
{
public function __construct(
public int $status,
public string $body,
public string $contentType = '',
public bool $stale = false,
) {}
public function ok(): bool
{
return $this->status >= 200 && $this->status < 300;
}
/** Status fuer die Oberflaeche: ein Verbindungsfehler wird zu 502. */
public function statusOr(int $fallback = 502): int
{
return $this->status === 0 ? $fallback : $this->status;
}
public function decoded(): array
{
return Json::decode($this->body);
}
/** n8n liefert Listen mal als {"key":[...]}, mal als nacktes Array. */
public function items(string $key): array
{
return Json::listFrom($this->decoded(), $key);
}
public function asStale(): self
{
return new self($this->status, $this->body, $this->contentType, true);
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Rechnungen eines Kunden, die Gesamtuebersicht und die Zuordnung offener Belege.
*/
final readonly class CustomerInvoicesController extends RelayController
{
/** Die Rechnungsablage. Nur darunter darf ein PDF ausgeliefert werden. */
private const string PDF_ROOT = '/files/EK-DOS/40 Rechnungen/';
public function index(Request $request, Response $response): Response
{
$mode = $this->query($request, 'mode');
$wantsAll = $mode === 'all';
$secret = $this->invoiceSyncSecret();
if ($wantsAll && $secret === '') {
return Json::error($response, 'Die geschützte Rechnungsübersicht ist noch nicht eingerichtet.', 503);
}
$url = $wantsAll ? $this->n8nUrl->allInvoices() : $this->n8nUrl->invoices([
'kunde' => $this->query($request, 'kunde'),
'aliases' => $this->query($request, 'aliases'),
'mode' => $mode,
'liegenschaft' => $this->query($request, 'liegenschaft'),
]);
$reply = $this->cachedGet(CachePolicy::Invoices, $url, $wantsAll ? ['x-ekdos-invoice-sync' => $secret] : []);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Rechnungen konnten nicht geladen werden.');
}
$decoded = $reply->decoded();
return Json::write($response, [
'invoices' => is_array($decoded['invoices'] ?? null) ? array_values($decoded['invoices']) : [],
'properties' => is_array($decoded['properties'] ?? null) ? array_values($decoded['properties']) : [],
'stale' => $reply->stale ?: null,
]);
}
/** Einen offenen Beleg einem Kunden zuordnen. */
public function assign(Request $request, Response $response): Response
{
$body = $this->body($request);
$id = self::text($body, 'id');
$customer = self::text($body, 'kunde');
$secret = $this->invoiceSyncSecret();
if ($id === '' || $customer === '') {
return Json::error($response, 'Rechnung und Kunde müssen ausgewählt sein.', 400);
}
if ($secret === '') {
return Json::error($response, 'Die geschützte Rechnungszuordnung ist noch nicht eingerichtet.', 503);
}
$reply = $this->n8n->post(
$this->n8nUrl->assignInvoice(),
['id' => $id, 'kunde' => $customer, 'kundennummer' => self::text($body, 'kundennummer')],
['x-ekdos-invoice-sync' => $secret],
);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Rechnung konnte nicht zugeordnet werden.');
}
$this->cache->invalidate(CachePolicy::Invoices[0]);
return Json::passthrough($response, $reply->body);
}
/**
* Rechnungs-PDF.
*
* Der Pfad kommt aus der Oberflaeche, deshalb wird er streng geprueft: er muss
* unterhalb der Rechnungsablage liegen, auf .pdf enden und darf kein ".."
* enthalten. Ohne diese Pruefung waere die Route ein Dateibrowser fuer die NAS.
*/
public function pdf(Request $request, Response $response): Response
{
$path = $this->query($request, 'path');
if (!str_starts_with($path, self::PDF_ROOT) || !str_ends_with(strtolower($path), '.pdf') || str_contains($path, '..')) {
return Json::error($response, 'Ungültiger Rechnungspfad.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->invoicePdf($path));
if (!$reply->ok() || $reply->body === '') {
return $this->upstreamError($response, $reply, 'Die Rechnung ist nicht verfügbar.');
}
return $this->pdfResponse($response, $reply->body, 'Rechnung.pdf');
}
/** Stoesst den Abgleich der Rechnungsablage in n8n an. */
public function sync(Request $request, Response $response): Response
{
$secret = $this->invoiceSyncSecret();
if ($secret === '') {
return Json::error($response, 'Der geschützte Rechnungsabgleich ist noch nicht eingerichtet.', 503);
}
$reply = $this->n8n->post($this->n8nUrl->invoiceSync(), null, ['x-ekdos-invoice-sync' => $secret]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Rechnungsabgleich konnte nicht durchgeführt werden.');
}
// Nach einem Abgleich stimmt keine der Rechnungslisten mehr.
$this->cache->invalidate(
CachePolicy::Invoices[0],
CachePolicy::InvoicesCreate[0],
CachePolicy::InvoicesReview[0],
CachePolicy::InvoicesSend[0],
);
return $response->withStatus(204);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class CustomersController extends RelayController
{
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Customers, $this->n8nUrl->customers(), $this->readHeaders());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Kundenstamm konnte nicht aus n8n geladen werden.');
}
return $this->listResponse($response, $reply, 'customers', 'customers');
}
/** Neuanlage. n8n verlangt dafuer das geteilte Geheimnis, nicht den Lese-Schluessel. */
public function create(Request $request, Response $response): Response
{
$secret = $this->invoiceSyncSecret();
if ($secret === '') {
return Json::error($response, 'Die sichere Kundenanlage ist noch nicht eingerichtet.', 503);
}
$reply = $this->n8n->post($this->n8nUrl->customers(), $this->body($request), ['x-ekdos-invoice-sync' => $secret]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht im Kundenstamm gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Customers[0]);
return Json::write($response, ['customer' => $reply->decoded()], 201);
}
public function update(Request $request, Response $response): Response
{
$reply = $this->n8n->put($this->n8nUrl->customers(), $this->body($request), $this->readHeaders());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht im Kundenstamm gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Customers[0]);
return Json::write($response, ['customer' => $reply->decoded()]);
}
/** Vormals: nur Sascha. */
public function delete(Request $request, Response $response): Response
{
$reply = $this->n8n->delete($this->n8nUrl->customers(), $this->body($request), $this->readHeaders());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht aus dem Kundenstamm gelöscht werden.');
}
$this->cache->invalidate(CachePolicy::Customers[0]);
return Json::write($response, ['deleted' => true]);
}
public function digitalFile(Request $request, Response $response): Response
{
$customer = $this->query($request, 'customer');
if ($customer === '' || mb_strlen($customer) > 160) {
return Json::error($response, 'Der Kunde fehlt.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->customerDigitalFile($customer));
if (!$reply->ok() || $reply->body === '') {
return $this->upstreamError($response, $reply, 'Die Digitale Akte ist nicht verfügbar.');
}
return $this->pdfResponse($response, $reply->body, 'Digitale_Akte.pdf');
}
/**
* Der Lese-Schluessel des Kundenstamm-Workflows.
*
* Er stand frueher als Literal im Quelltext; jetzt kommt er aus der Umgebung,
* damit er rotiert werden kann, ohne die Anwendung neu zu bauen.
*/
private function readHeaders(): array
{
$key = $this->config->string('N8N_CUSTOMER_KEY');
return $key === '' ? [] : ['X-EK-DOS-Customer-Key' => $key];
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Stundennachweise.
*
* Ein schwerer Report, den n8n aus mehreren Quellen zusammensetzt. Er wird
* deshalb am laengsten zwischengespeichert (5 Minuten frisch, 1 Stunde Rueckfall).
*/
final readonly class HoursController extends RelayController
{
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Hours, $this->n8nUrl->hours());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Stundennachweise konnten nicht aus n8n geladen werden.');
}
$decoded = $reply->decoded();
// n8n liefert den Report je nach Workflow-Zweig als Objekt oder als Liste mit einem Element.
$source = array_is_list($decoded) ? (is_array($decoded[0] ?? null) ? $decoded[0] : []) : $decoded;
$employees = [];
foreach ((is_array($source['employees'] ?? null) ? $source['employees'] : []) as $row) {
if (is_array($row) && ($employee = self::mapEmployee($row)) !== null) {
$employees[] = $employee;
}
}
$payload = [
'month' => self::text($source, 'month'),
'year' => self::number($source, 'year'),
'generatedAt' => self::text($source, 'generatedAt') !== '' ? self::text($source, 'generatedAt') : gmdate('c'),
'employees' => $employees,
'statusPriority' => self::text($source, 'statusPriority') !== '' ? self::text($source, 'statusPriority') : null,
];
if ($reply->stale) {
$payload['stale'] = true;
}
return Json::write($response, $payload);
}
/** @return array<string, mixed>|null */
private static function mapEmployee(array $row): ?array
{
$name = self::text($row, 'name');
if ($name === '') {
return null;
}
$entries = [];
foreach ((is_array($row['entries'] ?? null) ? $row['entries'] : []) as $entry) {
if (is_array($entry) && !array_is_list($entry)) {
$entries[] = self::mapEntry($entry);
}
}
return [
'name' => $name,
'totalHours' => self::number($row, 'totalHours'),
'entries' => $entries,
'workDaysYear' => self::number($row, 'workDaysYear'),
];
}
/** @return array<string, mixed> */
private static function mapEntry(array $entry): array
{
$date = self::text($entry, 'date');
$label = self::text($entry, 'label');
$status = self::text($entry, 'status');
return [
'date' => $date,
'label' => $label !== '' ? $label : $date,
'status' => $status !== '' ? $status : 'Betrieb',
'customer' => self::optional($entry, 'customer'),
'property' => self::optional($entry, 'property'),
'report' => self::optional($entry, 'report'),
'hours' => self::number($entry, 'hours'),
];
}
private static function number(array $source, string $key): float
{
$value = $source[$key] ?? null;
return is_numeric($value) ? (float) $value : 0.0;
}
private static function optional(array $source, string $key): ?string
{
$value = self::text($source, $key);
return $value !== '' ? $value : null;
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class InternalTasksController extends RelayController
{
private const array RECIPIENTS = ['sascha', 'svenja'];
private const array CATEGORIES = [
'angebote',
'steuerberater',
'kundenruecksprache',
'interne_bueroaufgaben',
'heute_erledigen',
];
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Tasks, $this->n8nUrl->internalTasks());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Interne Aufgaben konnten nicht geladen werden.');
}
return $this->listResponse($response, $reply, 'tasks', 'tasks');
}
public function create(Request $request, Response $response): Response
{
$body = $this->body($request);
$fields = self::validate($body);
if (is_string($fields)) {
return Json::error($response, $fields, 400);
}
$fields['erstellt_von'] = $this->session($request)->displayName;
$reply = $this->n8n->post($this->n8nUrl->internalTasks(), $fields);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Tasks[0]);
return Json::passthrough($response, $reply->body, 201);
}
public function complete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Aufgabe fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/erledigt'), [
'id' => $id,
'erledigt_von' => $this->session($request)->displayName,
]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht abgeschlossen werden.');
}
$this->cache->invalidate(CachePolicy::Tasks[0]);
return Json::passthrough($response, $reply->body);
}
public function edit(Request $request, Response $response): Response
{
$body = $this->body($request);
$id = self::text($body, 'id');
$fields = self::validate($body);
if ($id === '') {
return Json::error($response, 'Die Aufgabe fehlt.', 400);
}
if (is_string($fields)) {
return Json::error($response, 'Bitte Aufgabe, Empfänger und Kategorie vollständig angeben.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/bearbeiten'), ['id' => $id] + $fields);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht bearbeitet werden.');
}
$this->cache->invalidate(CachePolicy::Tasks[0]);
return Json::passthrough($response, $reply->body);
}
/** Vormals: nur Sascha. */
public function delete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Aufgabe fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/loeschen'), ['id' => $id]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht gelöscht werden.');
}
$this->cache->invalidate(CachePolicy::Tasks[0]);
return Json::passthrough($response, $reply->body);
}
/**
* @return array<string, string>|string Felder oder die Fehlermeldung.
*/
private static function validate(array $body): array|string
{
$task = self::text($body, 'aufgabe');
$recipient = self::text($body, 'empfaenger');
$category = self::text($body, 'kategorie');
if ($task === '') {
return 'Bitte Aufgabe eingeben.';
}
if (!in_array($recipient, self::RECIPIENTS, true) || !in_array($category, self::CATEGORIES, true)) {
return 'Bitte Empfänger und Kategorie auswählen.';
}
return [
'aufgabe' => $task,
'empfaenger' => $recipient,
'kategorie' => $category,
'kunden_id' => self::text($body, 'kunden_id'),
];
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Die drei Rechnungslisten des Buero-Ablaufs: anfertigen, pruefen, versenden.
*
* Anfertigen und Versenden waren frueher auf Svenja verdrahtet und haengen jetzt
* am Recht "Rechnungen bearbeiten und versenden". Die Pruefliste war in der
* Next.js-Fassung versehentlich voellig ungeschuetzt -- sie verlangt jetzt wie
* jede andere Route eine angemeldete Sitzung.
*/
final readonly class InvoicesController extends RelayController
{
public function createList(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::InvoicesCreate, $this->n8nUrl->invoicesCreate());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Rechnungsaufgaben-Schnittstelle ist nicht erreichbar.');
}
return Json::passthrough($response, $reply->body);
}
public function completeCreateTask(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Rechnungsaufgabe fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->invoicesCreateComplete(), ['id' => $id]);
$decoded = $reply->decoded();
// n8n meldet einen fachlichen Fehler auch mit HTTP 200 und ok:false.
if (!$reply->ok() || ($decoded['ok'] ?? null) === false) {
$message = self::text($decoded, 'error');
return Json::error($response, $message !== '' ? $message : 'Die Rechnungsaufgabe konnte nicht abgeschlossen werden.', 502);
}
$this->cache->invalidate(CachePolicy::InvoicesCreate[0], CachePolicy::InvoicesReview[0]);
return Json::passthrough($response, $reply->body);
}
public function reviewList(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::InvoicesReview, $this->n8nUrl->invoicesReview());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Prüflisten-Schnittstelle ist nicht erreichbar.');
}
return Json::passthrough($response, $reply->body);
}
public function sendList(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::InvoicesSend, $this->n8nUrl->invoicesSend());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Versandaufgaben-Schnittstelle ist nicht erreichbar.');
}
return Json::passthrough($response, $reply->body);
}
public function confirmSent(Request $request, Response $response): Response
{
$body = $this->body($request);
$id = self::text($body, 'id');
if ($id === '') {
return Json::error($response, 'Die Versandaufgabe fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->invoicesSend(), $body);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Versand konnte nicht bestätigt werden.');
}
$this->cache->invalidate(CachePolicy::InvoicesSend[0], CachePolicy::Invoices[0]);
return Json::passthrough($response, $reply->body);
}
/**
* Die Digitale Akte zu einer Rechnungsaufgabe.
* n8n liefert sie hier als base64 in einem JSON-Feld, nicht als Binaerrumpf.
*/
public function digitalFile(Request $request, Response $response): Response
{
$taskId = $this->query($request, 'taskId');
if ($taskId === '') {
return Json::error($response, 'Die Rechnungsaufgabe fehlt.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->digitalFile($taskId));
$decoded = $reply->decoded();
$contentBytes = self::text($decoded, 'contentBytes');
if (!$reply->ok() || $contentBytes === '') {
$message = self::text($decoded, 'error');
return Json::error(
$response,
$message !== '' ? $message : 'Die Digitale Akte ist nicht verfügbar (HTTP ' . $reply->statusOr(502) . ').',
502,
);
}
$bytes = base64_decode($contentBytes, true);
if ($bytes === false) {
return Json::error($response, 'Die Digitale Akte konnte nicht gelesen werden.', 502);
}
$name = self::text($decoded, 'name');
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name !== '' ? $name : 'Digitale_Akte.pdf') ?? 'Digitale_Akte.pdf';
return $this->pdfResponse($response, $bytes, $filename);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class OffersController extends RelayController
{
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Offers, $this->n8nUrl->offers());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Angebote konnten nicht aus n8n geladen werden.');
}
return $this->listResponse($response, $reply, 'offers', 'offers');
}
/** Versandstatus, Beauftragung und Ruecksetzung laufen ueber dieselbe Route. */
public function update(Request $request, Response $response): Response
{
$body = $this->body($request);
$id = self::text($body, 'id');
$action = self::text($body, 'action');
if ($id === '') {
return Json::error($response, 'Das Angebot fehlt.', 400);
}
return match ($action) {
'versendet' => $this->markSent($response, $body, $id),
'beauftragt' => $this->markCommissioned($response, $body, $id),
'zuruecksetzen' => $this->reset($response, $id),
default => Json::error($response, 'Unbekannte Angebotsaktion.', 400),
};
}
/** Vormals: nur Sascha. */
public function delete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Das Angebot fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->offers('/loeschen'), ['id' => $id]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Das Angebot konnte nicht gelöscht werden.');
}
$this->cache->invalidate(CachePolicy::Offers[0]);
return Json::passthrough($response, $reply->body);
}
private function markSent(Response $response, array $body, string $id): Response
{
$shipping = self::text($body, 'versandart');
if ($shipping !== 'E-Mail' && $shipping !== 'Post') {
return Json::error($response, 'Bitte E-Mail oder Post auswählen.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->offers('/versendet'), ['id' => $id, 'versandart' => $shipping]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Versandstatus konnte nicht gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Offers[0]);
return Json::passthrough($response, $reply->body);
}
private function markCommissioned(Response $response, array $body, string $id): Response
{
$reply = $this->n8n->post($this->n8nUrl->offers('/beauftragt'), [
'id' => $id,
'angebot_nummer' => self::text($body, 'angebot_nummer'),
'kunde' => self::text($body, 'kunde'),
'objekt_zusatz' => self::text($body, 'objekt_zusatz'),
]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Beauftragung konnte nicht gespeichert werden.');
}
// Eine Beauftragung erzeugt in n8n eine Rechnungsaufgabe, deshalb faellt
// auch der Rechnungs-Cache.
$this->cache->invalidate(CachePolicy::Offers[0], CachePolicy::InvoicesCreate[0]);
return Json::passthrough($response, $reply->body);
}
/** Geschuetzte Ruecksetzung: n8n verlangt hier das geteilte Geheimnis. */
private function reset(Response $response, string $id): Response
{
$secret = $this->invoiceSyncSecret();
if ($secret === '') {
return Json::error($response, 'Die geschützte Rücksetzung ist noch nicht eingerichtet.', 503);
}
$reply = $this->n8n->post($this->n8nUrl->offers('/zuruecksetzen'), ['id' => $id], ['x-ekdos-invoice-sync' => $secret]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Versandstatus konnte nicht zurückgesetzt werden.');
}
$this->cache->invalidate(CachePolicy::Offers[0]);
return Json::passthrough($response, $reply->body);
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class OnlinePurchasesController extends RelayController
{
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Purchases, $this->n8nUrl->onlinePurchases());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Online-Käufe konnten nicht aus n8n geladen werden.');
}
return $this->listResponse($response, $reply, 'purchases', 'purchases');
}
/** Vormals: nur Sascha. */
public function create(Request $request, Response $response): Response
{
$body = $this->body($request);
$article = self::text($body, 'artikel');
$marketplace = self::text($body, 'kaufort');
$date = self::text($body, 'kaufdatum');
if ($article === '' || $marketplace === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return Json::error($response, 'Artikel, Marktplatz und ein gültiges Kaufdatum sind erforderlich.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases(), ['artikel' => $article, 'kaufort' => $marketplace, 'kaufdatum' => $date]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht in n8n gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Purchases[0]);
return Json::write($response, ['purchase' => $reply->decoded()], 201);
}
/** Vormals: nur Svenja. */
public function complete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Kaufposition fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases('/erledigt'), ['id' => $id]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht abgeschlossen werden.');
}
$this->cache->invalidate(CachePolicy::Purchases[0]);
return Json::write($response, $reply->decoded() + ['erledigt_am' => gmdate('c')]);
}
/** Vormals: nur Sascha. */
public function delete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Kaufposition fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases('/loeschen'), ['id' => $id]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht gelöscht werden.');
}
$this->cache->invalidate(CachePolicy::Purchases[0]);
return Json::passthrough($response, $reply->body);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\Auth\Session;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\N8n\Cache;
use Ekdos\N8n\Client;
use Ekdos\N8n\Endpoints;
use Ekdos\N8n\Reply;
use Ekdos\Support\Config;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Gemeinsame Basis aller n8n-Weiterleitungen.
*
* Jeder abgeleitete Controller bleibt damit auf dem, was ihn ausmacht: welche
* Adresse, welche Gruppe, welche Meldung im Fehlerfall.
*/
abstract readonly class RelayController
{
public function __construct(
protected Client $n8n,
protected Endpoints $n8nUrl,
protected Cache $cache,
protected Config $config,
) {}
/**
* Holt eine Liste durch den Cache.
*
* @param array{0: string, 1: int, 2: int} $policy siehe CachePolicy
*/
protected function cachedGet(array $policy, string $url, array $headers = []): Reply
{
[$group, $ttl, $stale] = $policy;
return $this->cache->remember(
$group,
$url . '|' . json_encode($headers, JSON_THROW_ON_ERROR),
$ttl,
$stale,
fn (): Reply => $this->n8n->get($url, $headers),
);
}
protected function session(Request $request): Session
{
// RequireAuth laeuft vor jedem Controller, deshalb ist die Sitzung hier gesetzt.
return SessionMiddleware::of($request) ?? throw new \LogicException('Route ohne RequireAuth erreicht.');
}
protected function body(Request $request): array
{
$parsed = $request->getParsedBody();
if (is_array($parsed)) {
return $parsed;
}
return Json::decode((string) $request->getBody());
}
protected function query(Request $request, string $key, string $fallback = ''): string
{
$value = $request->getQueryParams()[$key] ?? null;
return is_string($value) ? trim($value) : $fallback;
}
/** Nimmt Zeichenketten und Zahlen an und liefert immer eine getrimmte Zeichenkette. */
protected static function text(array $source, string $key): string
{
$value = $source[$key] ?? null;
return is_string($value) || is_int($value) || is_float($value) ? trim((string) $value) : '';
}
/**
* Einheitliche Fehlerantwort fuer einen fehlgeschlagenen n8n-Aufruf.
* Ein Verbindungsfehler (Status 0) wird zu 502.
*/
protected function upstreamError(Response $response, Reply $reply, string $message): Response
{
return Json::error($response, $message, $reply->statusOr(502));
}
/** Antwortet mit einer Liste und markiert, wenn sie aus dem Rueckfall-Cache stammt. */
protected function listResponse(Response $response, Reply $reply, string $key, string $as): Response
{
$payload = [$as => $reply->items($key), 'refreshedAt' => gmdate('c')];
if ($reply->stale) {
$payload['stale'] = true;
$payload['notice'] = 'n8n ist gerade nicht erreichbar. Angezeigt werden die zuletzt bekannten Daten.';
}
return Json::write($response, $payload);
}
/** Reicht eine Binaerantwort (PDF) unveraendert durch. */
protected function pdfResponse(Response $response, string $bytes, string $filename): Response
{
$response->getBody()->write($bytes);
return $response->withHeader('Content-Type', 'application/pdf')
->withHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->withHeader('Cache-Control', 'no-store');
}
/** Geteiltes Geheimnis fuer die geschuetzten n8n-Workflows. */
protected function invoiceSyncSecret(): string
{
return $this->config->string('N8N_INVOICE_SYNC_SECRET');
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class TicketsController extends RelayController
{
/** Ticketnummern haben die Form 2026-021. */
private const string TICKET_PATTERN = '/^20\d{2}-\d{3}$/';
/**
* Die offene Ticketliste. n8n liefert deutsche Feldnamen, die Oberflaeche
* erwartet camelCase -- diese Abbildung ist die einzige echte Logik im Relay.
*/
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Tickets, $this->n8nUrl->openTickets());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Offene Tickets konnten nicht aus n8n geladen werden.');
}
$tickets = [];
foreach ($reply->items('tickets') as $row) {
if (!is_array($row)) {
continue;
}
$ticket = self::mapTicket($row);
// Zeilen ohne Ticketnummer sind fuer die Ansicht wertlos.
if ($ticket['id'] !== '') {
$tickets[] = $ticket;
}
}
$payload = ['tickets' => $tickets, 'refreshedAt' => gmdate('c')];
if ($reply->stale) {
$payload['stale'] = true;
$payload['notice'] = 'n8n ist gerade nicht erreichbar. Angezeigt werden die zuletzt bekannten Tickets.';
}
return Json::write($response, $payload);
}
/**
* Wird von Workflow4A nach einer Ticketerstellung aufgerufen.
*
* Anders als frueher ist das kein Platzhalter mehr: der Aufruf verwirft den
* Ticket-Cache, sodass die naechste Abfrage der Oberflaeche garantiert die
* neuen Daten sieht statt auf den TTL-Ablauf zu warten.
*/
public function refresh(Request $request, Response $response): Response
{
$secret = $this->config->string('N8N_REFRESH_SECRET');
// Entweder eine angemeldete Sitzung oder das geteilte Geheimnis aus n8n.
if (SessionMiddleware::of($request) === null) {
$presented = $request->getHeaderLine('x-ekdos-webhook-secret');
if ($secret === '' || !hash_equals($secret, $presented)) {
return Json::error($response, 'Bitte erneut anmelden.', 401);
}
}
$this->cache->invalidate(CachePolicy::Tickets[0]);
return Json::write($response, ['ok' => true, 'refreshedAt' => gmdate('c')]);
}
/** Ergebnis der Kundenruecksprache festhalten. Vormals: nur Svenja. */
public function consultation(Request $request, Response $response): Response
{
$body = $this->body($request);
$ticketNumber = self::text($body, 'ticketnummer');
$result = self::text($body, 'ergebnis');
if ($ticketNumber === '' || $result === '') {
return Json::error($response, 'Ticketnummer und Ergebnis sind erforderlich.', 400);
}
if (mb_strlen($result) > 4000) {
return Json::error($response, 'Das Ergebnis darf maximal 4.000 Zeichen enthalten.', 400);
}
// Vorher pruefen, ob schon ein Ergebnis hinterlegt ist. Bewusst ungecacht:
// hier zaehlt der aktuelle Stand, nicht ein 15 Sekunden alter.
$lookup = $this->n8n->get($this->n8nUrl->openTickets());
foreach ($lookup->items('tickets') as $row) {
if (!is_array($row)) {
continue;
}
$id = self::text($row, 'ticketnummer') !== '' ? self::text($row, 'ticketnummer') : self::text($row, 'id');
if ($id === $ticketNumber && self::text($row, 'kundenruecksprache_ergebnis') !== '') {
return Json::error($response, 'Für dieses Ticket wurde bereits ein Ergebnis gespeichert.', 409);
}
}
$reply = $this->n8n->post($this->n8nUrl->ticketConsultation(), ['ticketnummer' => $ticketNumber, 'ergebnis' => $result]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Das Ergebnis der Kundenrücksprache konnte nicht in n8n gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Tickets[0]);
return Json::write($response, ['ok' => true, 'updatedAt' => gmdate('c')]);
}
public function digitalFile(Request $request, Response $response): Response
{
$ticket = $this->query($request, 'ticket');
if (preg_match(self::TICKET_PATTERN, $ticket) !== 1) {
return Json::error($response, 'Die Ticketnummer ist ungültig.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->ticketDigitalFile($ticket));
if (!$reply->ok() || $reply->body === '') {
return $this->upstreamError($response, $reply, 'Die Digitale Akte ist nicht verfügbar.');
}
return $this->pdfResponse($response, $reply->body, 'Digitale_Akte.pdf');
}
public function serviceReport(Request $request, Response $response): Response
{
$ticket = $this->query($request, 'ticket');
if (preg_match(self::TICKET_PATTERN, $ticket) !== 1) {
return Json::error($response, 'Die Ticketnummer ist ungültig.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->ticketServiceReport($ticket));
if (!$reply->ok() || $reply->body === '') {
return $this->upstreamError($response, $reply, 'Der letzte Servicebericht ist nicht verfügbar.');
}
return $this->pdfResponse($response, $reply->body, 'Letzter_Servicebericht.pdf');
}
/** @return array<string, mixed> */
private static function mapTicket(array $row): array
{
$continuation = self::text($row, 'fortsetzung');
$decisionCode = self::text($row, 'entscheidung_code');
return [
'id' => self::firstOf($row, ['ticketnummer', 'id'], ''),
'status' => self::firstOf($row, ['status'], 'offen'),
'reportStatus' => self::optional($row, 'letzter_servicebericht_status'),
'customer' => self::firstOf($row, ['kunde', 'customer'], ''),
'property' => self::firstOf($row, ['liegenschaft', 'property'], ''),
'title' => self::firstOf($row, ['title'], ''),
'updatedAt' => self::firstOf($row, ['updatedAt', 'updated_at', 'erstellt_am', 'createdAt'], ''),
'continuation' => $continuation !== '' ? $continuation : null,
'decisionCode' => $decisionCode !== '' ? $decisionCode : null,
'requiresCustomerConsultation' => self::needsConsultation($decisionCode, $continuation),
'consultationResult' => self::optional($row, 'kundenruecksprache_ergebnis'),
'consultationUpdatedAt' => self::optional($row, 'kundenruecksprache_am'),
'consultationBy' => self::optional($row, 'kundenruecksprache_von'),
];
}
/**
* Ein Ticket braucht Ruecksprache, wenn n8n es entweder ausdruecklich so
* kennzeichnet oder der Fortsetzungstext des Technikers es so formuliert.
*/
private static function needsConsultation(string $decisionCode, string $continuation): bool
{
if ($decisionCode === 'TEILERLEDIGUNG_RUECKSPRACHE_KUNDE') {
return true;
}
// Der Text kommt aus einem PDF und traegt gelegentlich zusammengesetzte
// Umlaute, deshalb vor dem Vergleich nach NFKC normalisieren.
$normalized = class_exists(\Normalizer::class)
? (\Normalizer::normalize($continuation, \Normalizer::FORM_KC) ?: $continuation)
: $continuation;
return preg_match('/nimmt\s+mit\s+kunden\s+für\s+das\s+weitere\s+vorgehen\s+kontakt\s+auf/iu', $normalized) === 1;
}
/** Erstes gesetztes Feld aus der Liste, sonst der Vorgabewert. */
private static function firstOf(array $row, array $keys, string $fallback): string
{
foreach ($keys as $key) {
if (isset($row[$key]) && (is_string($row[$key]) || is_numeric($row[$key]))) {
return (string) $row[$key];
}
}
return $fallback;
}
/** Leere Felder werden zu null, damit die Oberflaeche sie ueberspringen kann. */
private static function optional(array $row, string $key): ?string
{
$value = self::text($row, $key);
return $value !== '' ? $value : null;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Ekdos\Support;
/** Die gelesene Umgebung, einmal beim Start eingesammelt. */
final readonly class Config
{
private function __construct(private array $values) {}
public static function fromEnvironment(array $environment): self
{
return new self($environment);
}
public function string(string $key, string $fallback = ''): string
{
$value = $this->values[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : $fallback;
}
public function int(string $key, int $fallback): int
{
$value = $this->values[$key] ?? null;
return is_numeric($value) ? (int) $value : $fallback;
}
public function bool(string $key, bool $fallback): bool
{
$value = $this->values[$key] ?? null;
if (is_bool($value)) {
return $value;
}
return match (is_string($value) ? strtolower(trim($value)) : null) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => $fallback,
};
}
/** true, sobald ein Wert (z. B. ein geteiltes Geheimnis) hinterlegt ist. */
public function has(string $key): bool
{
return $this->string($key) !== '';
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Ekdos\Support;
use Psr\Http\Message\ResponseInterface as Response;
/** Einheitliche JSON-Antworten. Alle Meldungen sind deutschsprachig und fuer die Oberflaeche gedacht. */
final class Json
{
public static function write(Response $response, mixed $payload, int $status = 200): Response
{
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$response->getBody()->write($body);
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')
->withHeader('Cache-Control', 'no-store')
->withStatus($status);
}
public static function error(Response $response, string $message, int $status): Response
{
return self::write($response, ['error' => $message], $status);
}
/** Schreibt einen bereits von n8n gelieferten JSON-Rumpf unveraendert durch. */
public static function passthrough(Response $response, string $body, int $status = 200): Response
{
$response->getBody()->write($body === '' ? '{}' : $body);
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')
->withHeader('Cache-Control', 'no-store')
->withStatus($status);
}
/** Dekodiert einen n8n-Rumpf defensiv: kaputtes JSON wird zu einem leeren Array. */
public static function decode(string $body): array
{
$decoded = json_decode($body, true);
return is_array($decoded) ? $decoded : [];
}
/**
* n8n liefert Listen mal als {"tickets":[...]}, mal als nacktes Array.
* Diese Helferin nimmt beide Formen an.
*/
public static function listFrom(array $decoded, string $key): array
{
if (isset($decoded[$key]) && is_array($decoded[$key])) {
return array_values($decoded[$key]);
}
return array_is_list($decoded) ? $decoded : [];
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
/**
* Feingranulare Rechte statt Namensabfragen. Jede Prüfung im alten Code
* ("nur Sascha", "nur Svenja") entspricht genau einem Eintrag hier.
*/
final class Permission
{
/** Benutzer anlegen, ändern, löschen. */
public const string UsersManage = 'users.manage';
/** Vormals: nur Sascha. */
public const string OffersDelete = 'offers.delete';
public const string CustomersDelete = 'customers.delete';
public const string TasksDelete = 'tasks.delete';
public const string PurchasesCreate = 'purchases.create';
public const string PurchasesDelete = 'purchases.delete';
/** Vormals: nur Svenja. */
public const string PurchasesComplete = 'purchases.complete';
public const string InvoicesProcess = 'invoices.process';
/** @var list<string> */
public const array ALL = [
self::UsersManage,
self::OffersDelete,
self::CustomersDelete,
self::TasksDelete,
self::PurchasesCreate,
self::PurchasesDelete,
self::PurchasesComplete,
self::InvoicesProcess,
];
/** Menschenlesbare Bezeichnung für die Benutzerverwaltung. */
public static function label(string $permission): string
{
return match ($permission) {
self::UsersManage => 'Benutzerverwaltung',
self::OffersDelete => 'Angebote löschen',
self::CustomersDelete => 'Kunden löschen',
self::TasksDelete => 'Aufgaben löschen',
self::PurchasesCreate => 'Online-Käufe eintragen',
self::PurchasesDelete => 'Online-Käufe löschen',
self::PurchasesComplete => 'Online-Käufe abschließen',
self::InvoicesProcess => 'Rechnungen bearbeiten und versenden',
default => $permission,
};
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
/**
* Die drei Rollen bilden ab, was vorher fest auf die Namen "sascha" und "svenja"
* verdrahtet war. "inhaber" entspricht Saschas Rechten, "buero" denen von Svenja.
*/
enum Role: string
{
case Admin = 'admin';
case Inhaber = 'inhaber';
case Buero = 'buero';
public function label(): string
{
return match ($this) {
self::Admin => 'Administration',
self::Inhaber => 'Inhaber',
self::Buero => 'Büro',
};
}
/** @return list<string> */
public function permissions(): array
{
return match ($this) {
self::Admin => Permission::ALL,
self::Inhaber => [
Permission::OffersDelete,
Permission::CustomersDelete,
Permission::TasksDelete,
Permission::PurchasesCreate,
Permission::PurchasesDelete,
],
self::Buero => [
Permission::PurchasesComplete,
Permission::InvoicesProcess,
],
};
}
public function allows(string $permission): bool
{
return in_array($permission, $this->permissions(), true);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
final readonly class User
{
public function __construct(
public string $id,
public string $username,
public string $displayName,
public Role $role,
public bool $isActive,
public string $createdAt,
public ?string $lastLoginAt = null,
public string $passwordHash = '',
) {}
public static function fromRow(array $row): self
{
return new self(
id: (string) $row['id'],
username: (string) $row['username'],
displayName: (string) $row['display_name'],
role: Role::from((string) $row['role']),
isActive: (bool) $row['is_active'],
createdAt: (string) $row['created_at'],
lastLoginAt: isset($row['last_login_at']) ? (string) $row['last_login_at'] : null,
passwordHash: (string) ($row['password_hash'] ?? ''),
);
}
/** Die Form, die die Oberflaeche sieht. Der Hash verlaesst den Server nie. */
public function toArray(): array
{
return [
'id' => $this->id,
'username' => $this->username,
'displayName' => $this->displayName,
'role' => $this->role->value,
'roleLabel' => $this->role->label(),
'isActive' => $this->isActive,
'createdAt' => $this->createdAt,
'lastLoginAt' => $this->lastLoginAt,
'permissions' => $this->role->permissions(),
];
}
public function can(string $permission): bool
{
return $this->role->allows($permission);
}
}
+237
View File
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
use Ekdos\Auth\AuthService;
use Ekdos\Auth\SessionStore;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Benutzerverwaltung.
*
* Alle Routen liegen hinter dem Recht "users.manage" (Rolle Administration).
* Vier Regeln schuetzen davor, sich selbst auszusperren:
*
* 1. Der letzte aktive Administrator kann weder geloescht noch herabgestuft
* noch deaktiviert werden.
* 2. Niemand kann das eigene Konto loeschen.
* 3. Niemand kann die eigene Rolle aendern.
* 4. Jede Rechteaenderung meldet den betroffenen Benutzer sofort ab, damit
* eine laufende Sitzung keine Rechte behaelt, die sie nicht mehr hat.
*/
final readonly class UserController
{
public function __construct(
private UserRepository $users,
private SessionStore $sessions,
) {}
public function index(Request $request, Response $response): Response
{
return Json::write($response, [
'users' => array_map(static fn (User $user): array => $user->toArray(), $this->users->all()),
'roles' => self::roleCatalogue(),
]);
}
public function create(Request $request, Response $response): Response
{
$actor = SessionMiddleware::of($request);
$body = self::body($request);
$username = trim((string) ($body['username'] ?? ''));
$displayName = trim((string) ($body['displayName'] ?? ''));
$password = (string) ($body['password'] ?? '');
$role = Role::tryFrom((string) ($body['role'] ?? ''));
if (($problem = self::validateName($username, $displayName)) !== null) {
return Json::error($response, $problem, 400);
}
if ($role === null) {
return Json::error($response, 'Bitte eine gültige Rolle auswählen.', 400);
}
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
return Json::error($response, $problem, 400);
}
if ($this->users->usernameTaken($username)) {
return Json::error($response, 'Dieser Benutzername ist bereits vergeben.', 409);
}
$user = $this->users->create($username, $displayName, $role, AuthService::hash($password));
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.create', $user->id, $user->username, ['role' => $role->value]);
return Json::write($response, ['user' => $user->toArray()], 201);
}
public function update(Request $request, Response $response, array $args): Response
{
$actor = SessionMiddleware::of($request);
$id = (string) ($args['id'] ?? '');
$user = $this->users->find($id);
if ($user === null) {
return Json::error($response, 'Dieser Benutzer existiert nicht.', 404);
}
$body = self::body($request);
$fields = [];
$changed = [];
if (array_key_exists('displayName', $body)) {
$displayName = trim((string) $body['displayName']);
if ($displayName === '' || mb_strlen($displayName) > 120) {
return Json::error($response, 'Der Anzeigename muss zwischen 1 und 120 Zeichen lang sein.', 400);
}
$fields['display_name'] = $displayName;
$changed[] = 'Anzeigename';
}
if (array_key_exists('username', $body)) {
$username = trim((string) $body['username']);
if (($problem = self::validateName($username, 'x')) !== null) {
return Json::error($response, $problem, 400);
}
if ($this->users->usernameTaken($username, $user->id)) {
return Json::error($response, 'Dieser Benutzername ist bereits vergeben.', 409);
}
$fields['username'] = $username;
$changed[] = 'Benutzername';
}
if (array_key_exists('role', $body)) {
$role = Role::tryFrom((string) $body['role']);
if ($role === null) {
return Json::error($response, 'Bitte eine gültige Rolle auswählen.', 400);
}
if ($actor?->userId === $user->id && $role !== $user->role) {
return Json::error($response, 'Die eigene Rolle kann nicht geändert werden.', 409);
}
if ($user->role === Role::Admin && $role !== Role::Admin && $this->users->countAdmins($user->id) === 0) {
return Json::error($response, 'Es muss mindestens ein aktiver Administrator bestehen bleiben.', 409);
}
$fields['role'] = $role->value;
$changed[] = 'Rolle';
}
if (array_key_exists('isActive', $body)) {
$isActive = (bool) $body['isActive'];
if (!$isActive && $user->role === Role::Admin && $this->users->countAdmins($user->id) === 0) {
return Json::error($response, 'Der letzte aktive Administrator kann nicht deaktiviert werden.', 409);
}
if (!$isActive && $actor?->userId === $user->id) {
return Json::error($response, 'Das eigene Konto kann nicht deaktiviert werden.', 409);
}
$fields['is_active'] = $isActive;
$changed[] = $isActive ? 'aktiviert' : 'deaktiviert';
}
if (array_key_exists('password', $body) && (string) $body['password'] !== '') {
$password = (string) $body['password'];
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
return Json::error($response, $problem, 400);
}
$fields['password_hash'] = AuthService::hash($password);
$changed[] = 'Passwort';
}
if ($fields === []) {
return Json::error($response, 'Es wurde nichts geändert.', 400);
}
$updated = $this->users->update($user->id, $fields);
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.update', $user->id, $user->username, ['changed' => $changed]);
// Rolle, Aktivierung oder Passwort geaendert: laufende Sitzungen beenden.
$forcesLogout = isset($fields['role']) || isset($fields['is_active']) || isset($fields['password_hash']);
$endedSessions = $forcesLogout ? $this->sessions->destroyAllFor($user->id) : 0;
return Json::write($response, [
'user' => $updated?->toArray(),
'endedSessions' => $endedSessions,
]);
}
public function delete(Request $request, Response $response, array $args): Response
{
$actor = SessionMiddleware::of($request);
$id = (string) ($args['id'] ?? '');
$user = $this->users->find($id);
if ($user === null) {
return Json::error($response, 'Dieser Benutzer existiert nicht.', 404);
}
if ($actor?->userId === $user->id) {
return Json::error($response, 'Das eigene Konto kann nicht gelöscht werden.', 409);
}
if ($user->role === Role::Admin && $this->users->countAdmins($user->id) === 0) {
return Json::error($response, 'Der letzte aktive Administrator kann nicht gelöscht werden.', 409);
}
// Erst abmelden, dann loeschen: sonst bliebe eine offene Sitzung ohne Konto zurueck.
$this->sessions->destroyAllFor($user->id);
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.delete', null, $user->username, ['role' => $user->role->value]);
$this->users->delete($user->id);
return Json::write($response, ['deleted' => true]);
}
/** Das Protokoll der Benutzerverwaltung, jüngste Einträge zuerst. */
public function audit(Request $request, Response $response): Response
{
return Json::write($response, ['entries' => $this->users->recentAudit(50)]);
}
/** @return list<array<string, mixed>> */
private static function roleCatalogue(): array
{
return array_map(static fn (Role $role): array => [
'value' => $role->value,
'label' => $role->label(),
'permissions' => array_map(Permission::label(...), $role->permissions()),
], Role::cases());
}
private static function validateName(string $username, string $displayName): ?string
{
if (preg_match('/^[a-z0-9._-]{3,40}$/i', $username) !== 1) {
return 'Der Benutzername darf 3 bis 40 Zeichen lang sein und nur Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.';
}
if ($displayName === '' || mb_strlen($displayName) > 120) {
return 'Der Anzeigename muss zwischen 1 und 120 Zeichen lang sein.';
}
return null;
}
private static function body(Request $request): array
{
$parsed = $request->getParsedBody();
return is_array($parsed) ? $parsed : Json::decode((string) $request->getBody());
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
use PDO;
final readonly class UserRepository
{
private const string COLUMNS = 'id, username, display_name, role, is_active, created_at, updated_at, last_login_at';
public function __construct(private PDO $db) {}
/** @return list<User> */
public function all(): array
{
$rows = $this->db->query('select ' . self::COLUMNS . ' from users order by lower(display_name)')->fetchAll();
return array_map(User::fromRow(...), $rows);
}
public function find(string $id): ?User
{
$statement = $this->db->prepare('select ' . self::COLUMNS . ' from users where id = :id');
$statement->execute(['id' => $id]);
$row = $statement->fetch();
return $row === false ? null : User::fromRow($row);
}
/** Liefert den Hash mit. Wird ausschliesslich fuer die Anmeldung verwendet. */
public function findByUsernameWithHash(string $username): ?User
{
$statement = $this->db->prepare('select ' . self::COLUMNS . ', password_hash from users where lower(username) = lower(:username)');
$statement->execute(['username' => $username]);
$row = $statement->fetch();
return $row === false ? null : User::fromRow($row);
}
public function usernameTaken(string $username, ?string $exceptId = null): bool
{
$statement = $this->db->prepare('select 1 from users where lower(username) = lower(:username) and (cast(:except as uuid) is null or id <> cast(:except as uuid))');
$statement->execute(['username' => $username, 'except' => $exceptId]);
return $statement->fetchColumn() !== false;
}
/** Zaehlt aktive Administratoren, optional ohne einen bestimmten Benutzer. */
public function countAdmins(?string $exceptId = null): int
{
$statement = $this->db->prepare("select count(*) from users where role = 'admin' and is_active and (cast(:except as uuid) is null or id <> cast(:except as uuid))");
$statement->execute(['except' => $exceptId]);
return (int) $statement->fetchColumn();
}
public function create(string $username, string $displayName, Role $role, string $passwordHash, bool $isActive = true): User
{
$statement = $this->db->prepare(
'insert into users (username, display_name, role, password_hash, is_active)
values (:username, :display_name, :role, :password_hash, :is_active)
returning ' . self::COLUMNS
);
$statement->execute([
'username' => $username,
'display_name' => $displayName,
'role' => $role->value,
'password_hash' => $passwordHash,
'is_active' => $isActive ? 't' : 'f',
]);
return User::fromRow($statement->fetch());
}
/** Nur uebergebene Felder werden geschrieben, alles andere bleibt unangetastet. */
public function update(string $id, array $fields): ?User
{
$allowed = ['username', 'display_name', 'role', 'password_hash', 'is_active'];
$sets = [];
$parameters = ['id' => $id];
foreach ($fields as $column => $value) {
if (!in_array($column, $allowed, true)) {
continue;
}
$sets[] = $column . ' = :' . $column;
$parameters[$column] = is_bool($value) ? ($value ? 't' : 'f') : $value;
}
if ($sets === []) {
return $this->find($id);
}
$sets[] = 'updated_at = now()';
$statement = $this->db->prepare('update users set ' . implode(', ', $sets) . ' where id = :id returning ' . self::COLUMNS);
$statement->execute($parameters);
$row = $statement->fetch();
return $row === false ? null : User::fromRow($row);
}
public function delete(string $id): bool
{
$statement = $this->db->prepare('delete from users where id = :id');
$statement->execute(['id' => $id]);
return $statement->rowCount() > 0;
}
public function touchLogin(string $id): void
{
$statement = $this->db->prepare('update users set last_login_at = now() where id = :id');
$statement->execute(['id' => $id]);
}
public function audit(?string $actorId, string $actorName, string $action, ?string $subjectId, string $subject, array $detail = []): void
{
$statement = $this->db->prepare(
'insert into user_audit (actor_id, actor_name, action, subject_id, subject, detail)
values (cast(:actor_id as uuid), :actor_name, :action, cast(:subject_id as uuid), :subject, cast(:detail as jsonb))'
);
$statement->execute([
'actor_id' => $actorId,
'actor_name' => $actorName,
'action' => $action,
'subject_id' => $subjectId,
'subject' => $subject,
'detail' => json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
]);
}
/** @return list<array<string, mixed>> */
public function recentAudit(int $limit = 50): array
{
$statement = $this->db->prepare('select actor_name, action, subject, detail, created_at from user_audit order by created_at desc limit :limit');
$statement->bindValue('limit', $limit, PDO::PARAM_INT);
$statement->execute();
return array_map(static fn (array $row): array => [
'actor' => $row['actor_name'],
'action' => $row['action'],
'subject' => $row['subject'],
'detail' => json_decode((string) $row['detail'], true) ?: [],
'createdAt' => $row['created_at'],
], $statement->fetchAll());
}
}