Initial
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
/.env
|
||||
composer.lock.bak
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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: '');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) !== '';
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user