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

This commit is contained in:
Kyle Müller
2026-09-06 13:25:44 +02:00
commit 7d82e807fc
71 changed files with 10601 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
name: EK-DOS-WEB bauen und ausrollen
on:
push:
branches: [main]
workflow_dispatch:
env:
DEPLOY_HOST: 10.0.11.131
DEPLOY_USER: deploy
DEPLOY_ROOT: /var/www/ekdos
jobs:
# ──────────────────────────────────────────────────────────────────────────
# Die Oberfläche wird hier zu statischen Dateien kompiliert. Node existiert
# ausschliesslich in diesem Container; auf dem Zielserver läuft keines.
# ──────────────────────────────────────────────────────────────────────────
frontend:
runs-on: ubuntu-latest
container: node:22-bookworm-slim
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: pnpm bereitstellen
run: corepack enable && corepack prepare pnpm@10.16.1 --activate
- name: Abhängigkeiten installieren
run: pnpm install --frozen-lockfile
# Typprüfung getrennt, damit ein Typfehler nicht als Bündelfehler erscheint.
- name: Typen prüfen
run: pnpm typecheck
- name: Bündel bauen
run: pnpm build
- uses: actions/upload-artifact@v3
with:
name: frontend-dist
path: frontend/dist
retention-days: 7
# ──────────────────────────────────────────────────────────────────────────
# Das Backend wird nicht kompiliert, aber geprüft: Syntax aller Dateien und
# ein Abhängigkeitsbaum ohne Entwicklungspakete.
# ──────────────────────────────────────────────────────────────────────────
backend:
runs-on: ubuntu-latest
container: php:8.5-cli
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Composer und Erweiterungen bereitstellen
run: |
apt-get update && apt-get install -y --no-install-recommends git unzip libpq-dev
docker-php-ext-install pdo_pgsql
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- name: Abhängigkeiten installieren
run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-req=ext-redis
- name: Syntax prüfen
run: find src public bin -type f \( -name '*.php' -o -name 'ekdos' \) -print0 | xargs -0 -n1 php -l
- uses: actions/upload-artifact@v3
with:
name: backend-build
path: |
backend
!backend/.env
retention-days: 7
# ──────────────────────────────────────────────────────────────────────────
# Ausrollen. Jede Veröffentlichung landet in einem eigenen Verzeichnis; erst
# der Symlink-Tausch macht sie sichtbar. Ein Fehlschlag lässt die laufende
# Fassung unberührt.
# ──────────────────────────────────────────────────────────────────────────
deploy:
runs-on: ubuntu-latest
needs: [frontend, backend]
if: gitea.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v3
with:
name: frontend-dist
path: dist
- uses: actions/download-artifact@v3
with:
name: backend-build
path: backend
- name: SSH einrichten
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
- name: Oberfläche übertragen
run: |
RELEASE="$DEPLOY_ROOT/releases/${{ gitea.sha }}"
ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$RELEASE'"
rsync -az --delete dist/ "$DEPLOY_USER@$DEPLOY_HOST:$RELEASE/"
- name: Backend übertragen
run: |
# .env bleibt auf dem Server und wird nie überschrieben.
rsync -az --delete \
--exclude '.env' \
--exclude 'var/cache' \
backend/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_ROOT/backend/"
- name: Umschalten und neu laden
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" bash -euo pipefail <<'REMOTE'
cd /var/www/ekdos
# Schema fortschreiben, bevor die neue Oberfläche sichtbar wird.
php backend/bin/ekdos migrate
# Atomarer Tausch: ln -sfn auf ein temporäres Ziel, dann umbenennen.
ln -sfn "releases/${GITEA_SHA:-$(ls -1t releases | head -1)}" current.new
mv -Tf current.new current
# Opcache hält den alten Quelltext, bis fpm neu lädt.
sudo systemctl reload php8.5-fpm
# Zwischengespeicherte n8n-Antworten passen womöglich nicht mehr
# zum neuen Abbildungscode.
php backend/bin/ekdos cache:flush
# Die letzten fünf Veröffentlichungen behalten.
ls -1t releases | tail -n +6 | xargs -r -I{} rm -rf "releases/{}"
REMOTE
env:
GITEA_SHA: ${{ gitea.sha }}
- name: Gesundheitsprüfung
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" \
'curl -fsS -o /dev/null -w "%{http_code}\n" http://127.0.0.1/api/health'
+29
View File
@@ -0,0 +1,29 @@
# Abhängigkeiten
node_modules/
backend/vendor/
# Baugebnisse
frontend/dist/
backend/var/
# Umgebung: gehört auf den Server, nie ins Repository
.env
.env.*
!.env.example
# Werkzeugreste
*.tsbuildinfo
.wrangler/
.sites-runtime/
.vite/
# Editor und Betriebssystem
.vscode/
.idea/
.DS_Store
Thumbs.db
# Die abgelöste Next.js-Fassung liegt bewusst noch im Arbeitsverzeichnis,
# gehört aber nicht in die neue Versionsgeschichte. Zeile entfernen, wer sie
# vor dem Löschen doch einchecken möchte.
legacy-nextjs/
+112
View File
@@ -0,0 +1,112 @@
# EK-DOS-WEB
Büroanwendung der Elektro Krüger GmbH. Tickets, Angebote, Rechnungen, Kundenstamm,
interne Aufgaben, Stundennachweise und Online-Käufe -- die Fachdaten liegen in
n8n, die Benutzer in einer eigenen Datenbank.
```
Browser
│ HTTPS, Sitzungscookie (undurchsichtig)
nginx ────────────────┬──────────────────────────────┐
│ statisch │ /api/ über Unix-Socket │
▼ ▼ │
React-Bündel php8.5-fpm ──► PostgreSQL (Benutzer, Rollen, Protokoll)
(nur Dateien) Slim 4 ──► Redis (Sitzungen, n8n-Cache)
──► n8n (alle Fachdaten)
```
Auf dem Server läuft **kein Node**. Die Oberfläche wird im Gitea-Lauf zu
statischen Dateien kompiliert; ausgeliefert wird sie von nginx.
## Verzeichnisse
| Ordner | Inhalt |
| ------------------- | ----------------------------------------------------------------- |
| `backend/` | PHP 8.5, Slim 4. BFF-Sitzung, Benutzerverwaltung, n8n-Weiterleitung |
| `frontend/` | React 19 + Vite. Baut nach `frontend/dist/` |
| `deploy/nginx/` | nginx-Site |
| `deploy/php-fpm/` | php-fpm-Pool |
| `.gitea/workflows/` | Bau- und Ausrollstrecke |
| `docs/` | Betrieb und n8n-Schnittstelle |
| `legacy-nextjs/` | Die abgelöste Next.js-Fassung. Kann nach dem Umstieg weg |
## Wie es zusammenhängt
**Die Anmeldung** ist ein Backend-For-Frontend: der Browser hält nur eine
zufällige Kennung im Cookie, alles Weitere -- Benutzer, Rolle, Rechte -- liegt in
Redis. Das Cookie wächst dadurch nicht mit den Rechten mit, und eine
Rechteänderung wirkt sofort, weil das Backend die Sitzung wegwerfen kann.
**Die Rechte** ersetzen die früheren Namensabfragen. Statt `user === "Sascha"`
gibt es `Permission::OffersDelete`, und Rollen bündeln diese Rechte:
| Rolle | darf zusätzlich |
| ---------------- | -------------------------------------------------------------------- |
| `admin` | alles, einschließlich Benutzerverwaltung |
| `inhaber` | Angebote/Kunden/Aufgaben löschen, Online-Käufe eintragen und löschen |
| `buero` | Online-Käufe abschließen, Rechnungen bearbeiten und versenden |
`inhaber` entspricht dem, was Sascha vorher durfte, `buero` dem von Svenja.
**Die n8n-Daten** kommen weiter live aus den Webhooks, aber durch einen kurzen
Redis-Cache. Drei Dinge passieren dort: eine frische Antwort wird für 15--300
Sekunden wiederverwendet; läuft sie ab, während mehrere Ansichten gleichzeitig
fragen, holt genau **eine** die Daten (Single-Flight); und ist n8n nicht
erreichbar, wird die letzte bekannte Antwort ausgeliefert statt einer
Fehlerseite. Jeder schreibende Aufruf verwirft seine Gruppe sofort.
## Erste Einrichtung
```bash
cd backend
cp .env.example .env # DB, Redis, n8n-Geheimnisse eintragen
composer install --no-dev --optimize-autoloader
php bin/ekdos migrate
php bin/ekdos user:create # der erste Administrator
php bin/ekdos check # Postgres, Redis und n8n prüfen
```
Details zu nginx, php-fpm und dem Ausrollen: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
## Lokal entwickeln
Zwei Fenster:
```bash
php -S 127.0.0.1:8080 -t backend/public
```
```bash
cd frontend && pnpm install && pnpm dev
```
Der Vite-Entwicklungsserver läuft auf `http://127.0.0.1:5173` und reicht `/api`
an den PHP-Server weiter. Ein anderes Backend lässt sich über `EKDOS_BACKEND`
angeben. Postgres und Redis müssen erreichbar sein; n8n nur, wenn die
Fachansichten Daten zeigen sollen -- Anmeldung und Benutzerverwaltung laufen ohne.
## Wartungs-CLI
```bash
php backend/bin/ekdos migrate # Schema fortschreiben
php backend/bin/ekdos user:create # Benutzer anlegen
php backend/bin/ekdos user:list # Benutzer auflisten
php backend/bin/ekdos user:password <name> # Passwort zurücksetzen
php backend/bin/ekdos cache:flush # n8n-Cache leeren
php backend/bin/ekdos check # Verbindungen prüfen
```
## Was noch offen ist
`frontend/src/views/Dashboard.tsx` ist unverändert die eine große Komponente aus
der Next.js-Fassung (rund 4.700 Zeilen, 22 Ansichten in einer Zustandsmaschine).
Sie wurde bewusst **kopiert statt neu geschrieben**, damit an der Darstellung
nichts unbemerkt verrutscht. Herausgelöst sind bisher Anmeldung, Sitzung und die
neue Benutzeransicht.
Die Aufteilung in eine Ansicht pro Datei ist der nächste sinnvolle Schritt.
Der Weg dorthin ohne Umschreiben der Markup-Blöcke: den Zustand in einen
`AppState`-Kontext heben, und jede Ansichtsdatei oben genau die Namen
destrukturieren, die ihr JSX schon verwendet -- dann bleibt das Markup wörtlich
unverändert.
+43
View File
@@ -0,0 +1,43 @@
# EK-DOS-WEB Backend Beispielkonfiguration.
# Kopieren nach .env und ausfuellen. Die Datei gehoert NICHT ins Repository.
APP_ENV=production
APP_DEBUG=false
# ── PostgreSQL: Benutzer, Rollen, Protokoll ────────────────────────────────
DB_DSN=pgsql:host=127.0.0.1;port=5432;dbname=ekdos
DB_USER=ekdos
DB_PASSWORD=
# ── Redis: Sitzungen (BFF-Ticket-Store) und n8n-Antwort-Cache ─────────────
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_PREFIX=ekdos:
# ── n8n ───────────────────────────────────────────────────────────────────
# Der Dienst auf Port 5678 ist ausschliesslich ueber den CNAME erreichbar.
N8N_PUBLIC_BASE=https://n8n.elektro-krueger.eu
# Jeder weitere n8n-Dienst auf einem anderen Port wird ueber die RFC1918-Adresse
# angesprochen, z. B. N8n::internal(5679, '/webhook/...') -> http://10.0.11.131:5679/...
N8N_INTERNAL_HOST=10.0.11.131
N8N_TIMEOUT=15
N8N_CONNECT_TIMEOUT=5
# Geteilte Geheimnisse fuer geschuetzte n8n-Workflows.
N8N_CUSTOMER_KEY=
N8N_INVOICE_SYNC_SECRET=
# Optional: erlaubt n8n, den Cache aktiv zu verwerfen (POST /api/tickets/refresh).
N8N_REFRESH_SECRET=
# ── Sitzung ───────────────────────────────────────────────────────────────
SESSION_COOKIE=ekdos_session
# Leerlauf in Sekunden, danach ist eine erneute Anmeldung noetig.
SESSION_TTL=3600
# Hinter TLS immer true lassen. Nur fuer reines HTTP im LAN auf false setzen.
SESSION_COOKIE_SECURE=true
# ── Anmeldebremse (Redis) ─────────────────────────────────────────────────
LOGIN_MAX_ATTEMPTS=10
LOGIN_WINDOW=900
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.env
composer.lock.bak
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Wartungs-CLI.
*
* php bin/ekdos migrate Schema anlegen oder fortschreiben
* php bin/ekdos user:create Benutzer anlegen (Passwort wird abgefragt)
* php bin/ekdos user:list Benutzer auflisten
* php bin/ekdos user:password <name> Passwort zuruecksetzen
* php bin/ekdos cache:flush n8n-Cache leeren
* php bin/ekdos check Postgres, Redis und n8n pruefen
*
* Der erste Administrator wird mit user:create angelegt. Es gibt bewusst keinen
* Bootstrap-Benutzer aus der Umgebung: ein Passwort in einer .env ist ein
* Passwort, das niemand mehr aendert.
*/
use Ekdos\Auth\AuthService;
use Ekdos\Bootstrap\Container;
use Ekdos\N8n\Cache;
use Ekdos\N8n\Client;
use Ekdos\N8n\Endpoints;
use Ekdos\Support\Config;
use Ekdos\Users\Role;
use Ekdos\Users\UserRepository;
require __DIR__ . '/../vendor/autoload.php';
$projectRoot = dirname(__DIR__);
$command = $argv[1] ?? 'help';
function out(string $line = ''): void
{
fwrite(STDOUT, $line . PHP_EOL);
}
function fail(string $line): never
{
fwrite(STDERR, $line . PHP_EOL);
exit(1);
}
/** Liest eine Eingabe ohne Bildschirmausgabe. */
function readSecret(string $prompt): string
{
fwrite(STDERR, $prompt);
if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) {
shell_exec('stty -echo 2>/dev/null');
}
$value = trim((string) fgets(STDIN));
if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) {
shell_exec('stty echo 2>/dev/null');
}
fwrite(STDERR, PHP_EOL);
return $value;
}
function ask(string $prompt, string $fallback = ''): string
{
fwrite(STDERR, $prompt);
$value = trim((string) fgets(STDIN));
return $value !== '' ? $value : $fallback;
}
$container = Container::build($projectRoot);
switch ($command) {
case 'migrate':
/** @var PDO $db */
$db = $container->get(PDO::class);
$db->exec('create table if not exists schema_migrations (version text primary key, applied_at timestamptz not null default now())');
$applied = $db->query('select version from schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
$files = glob($projectRoot . '/migrations/*.sql') ?: [];
sort($files);
$ran = 0;
foreach ($files as $file) {
$version = basename($file, '.sql');
if (in_array($version, $applied, true)) {
continue;
}
$sql = file_get_contents($file);
if ($sql === false) {
fail('Migration nicht lesbar: ' . $file);
}
$db->beginTransaction();
try {
$db->exec($sql);
$statement = $db->prepare('insert into schema_migrations (version) values (:version)');
$statement->execute(['version' => $version]);
$db->commit();
} catch (Throwable $exception) {
$db->rollBack();
fail('Migration ' . $version . ' fehlgeschlagen: ' . $exception->getMessage());
}
out(' angewendet: ' . $version);
$ran++;
}
out($ran === 0 ? 'Das Schema ist aktuell.' : $ran . ' Migration(en) angewendet.');
break;
case 'user:create':
/** @var UserRepository $users */
$users = $container->get(UserRepository::class);
$username = ask('Benutzername: ');
$displayName = ask('Anzeigename: ', $username);
$roleInput = ask('Rolle [admin|inhaber|buero] (admin): ', 'admin');
$role = Role::tryFrom($roleInput) ?? fail('Unbekannte Rolle: ' . $roleInput);
if ($username === '') {
fail('Der Benutzername darf nicht leer sein.');
}
if ($users->usernameTaken($username)) {
fail('Dieser Benutzername ist bereits vergeben.');
}
$password = readSecret('Passwort: ');
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
fail($problem);
}
if ($password !== readSecret('Passwort wiederholen: ')) {
fail('Die Passwörter stimmen nicht überein.');
}
$user = $users->create($username, $displayName, $role, AuthService::hash($password));
$users->audit(null, 'CLI', 'user.create', $user->id, $user->username, ['role' => $role->value]);
out('Angelegt: ' . $user->username . ' (' . $user->role->label() . ')');
break;
case 'user:list':
/** @var UserRepository $users */
$users = $container->get(UserRepository::class);
$rows = $users->all();
if ($rows === []) {
out('Es ist noch kein Benutzer angelegt. Anlegen mit: php bin/ekdos user:create');
break;
}
out(sprintf('%-22s %-24s %-14s %-8s %s', 'BENUTZER', 'NAME', 'ROLLE', 'AKTIV', 'LETZTE ANMELDUNG'));
foreach ($rows as $user) {
out(sprintf(
'%-22s %-24s %-14s %-8s %s',
$user->username,
$user->displayName,
$user->role->value,
$user->isActive ? 'ja' : 'nein',
$user->lastLoginAt ?? '',
));
}
break;
case 'user:password':
/** @var UserRepository $users */
$users = $container->get(UserRepository::class);
$username = $argv[2] ?? fail('Aufruf: php bin/ekdos user:password <benutzername>');
$user = $users->findByUsernameWithHash($username) ?? fail('Unbekannter Benutzer: ' . $username);
$password = readSecret('Neues Passwort für ' . $user->username . ': ');
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
fail($problem);
}
$users->update($user->id, ['password_hash' => AuthService::hash($password)]);
$container->get(\Ekdos\Auth\SessionStore::class)->destroyAllFor($user->id);
$users->audit(null, 'CLI', 'user.password', $user->id, $user->username);
out('Passwort geändert. Offene Sitzungen dieses Benutzers wurden beendet.');
break;
case 'cache:flush':
/** @var Cache $cache */
$cache = $container->get(Cache::class);
out($cache->flushAll() . ' zwischengespeicherte n8n-Antworten verworfen.');
break;
case 'check':
/** @var Config $config */
$config = $container->get(Config::class);
$problems = 0;
try {
$container->get(PDO::class)->query('select 1');
out(' Postgres erreichbar');
} catch (Throwable $exception) {
out(' Postgres FEHLER: ' . $exception->getMessage());
$problems++;
}
try {
$container->get(Redis::class)->ping();
out(' Redis erreichbar');
} catch (Throwable $exception) {
out(' Redis FEHLER: ' . $exception->getMessage());
$problems++;
}
/** @var Endpoints $endpoints */
$endpoints = $container->get(Endpoints::class);
/** @var Client $n8n */
$n8n = $container->get(Client::class);
$reply = $n8n->get($endpoints->openTickets());
if ($reply->status === 0) {
out(' n8n FEHLER: nicht erreichbar unter ' . $endpoints->openTickets());
$problems++;
} else {
out(' n8n HTTP ' . $reply->status . ' von ' . $endpoints->openTickets());
}
foreach (['N8N_CUSTOMER_KEY', 'N8N_INVOICE_SYNC_SECRET'] as $secret) {
if (!$config->has($secret)) {
out(' Hinweis ' . $secret . ' ist nicht gesetzt; betroffene Funktionen antworten mit 503.');
}
}
exit($problems === 0 ? 0 : 1);
default:
out('EK-DOS-WEB Wartungs-CLI');
out();
out(' php bin/ekdos migrate Schema anlegen oder fortschreiben');
out(' php bin/ekdos user:create Benutzer anlegen');
out(' php bin/ekdos user:list Benutzer auflisten');
out(' php bin/ekdos user:password <name> Passwort zurücksetzen');
out(' php bin/ekdos cache:flush n8n-Cache leeren');
out(' php bin/ekdos check Postgres, Redis und n8n prüfen');
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "elektro-krueger/ekdos-backend",
"description": "EK-DOS-WEB Backend: BFF-Cookie-Sitzung, Benutzerverwaltung und zwischengespeicherte n8n-Weiterleitung.",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.5",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_pgsql": "*",
"ext-redis": "*",
"guzzlehttp/guzzle": "^7.9",
"php-di/php-di": "^7.0",
"slim/psr7": "^1.7",
"slim/slim": "^4.14",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": {
"Ekdos\\": "src/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
},
"scripts": {
"migrate": "php bin/ekdos migrate",
"serve": "php -S 127.0.0.1:8080 -t public"
}
}
+34
View File
@@ -0,0 +1,34 @@
-- EK-DOS-WEB: Benutzer, Rollen und Protokoll.
-- Sitzungen und n8n-Antworten liegen in Redis, nicht hier.
create extension if not exists pgcrypto;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
username text not null,
display_name text not null,
role text not null default 'buero',
password_hash text not null,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
last_login_at timestamptz,
constraint users_role_check check (role in ('admin', 'inhaber', 'buero'))
);
-- Anmeldename ohne Ruecksicht auf Gross-/Kleinschreibung eindeutig.
create unique index if not exists users_username_key on users (lower(username));
create index if not exists users_active_idx on users (is_active) where is_active;
create table if not exists user_audit (
id bigserial primary key,
actor_id uuid references users (id) on delete set null,
actor_name text not null,
action text not null,
subject_id uuid,
subject text not null default '',
detail jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists user_audit_created_at_idx on user_audit (created_at desc);
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* Einziger Einstiegspunkt des Backends.
*
* nginx reicht ausschliesslich /api/ hierher weiter; alles andere ist die
* statisch gebaute Oberflaeche und wird direkt von nginx ausgeliefert.
*/
use Ekdos\Bootstrap\Container;
use Ekdos\Bootstrap\Routes;
use Ekdos\Http\Middleware\ErrorHandler;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\Support\Config;
use Slim\Factory\AppFactory;
require __DIR__ . '/../vendor/autoload.php';
$projectRoot = dirname(__DIR__);
$container = Container::build($projectRoot);
/** @var Config $config */
$config = $container->get(Config::class);
// Ausnahmen gehen in den Fehlerkanal von php-fpm, niemals in die Antwort.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
AppFactory::setContainer($container);
$app = AppFactory::create();
Routes::register($app);
// Reihenfolge: zuletzt hinzugefuegt laeuft zuerst.
// ErrorHandler -> Session -> Routing -> Body-Parsing -> Route.
$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
$app->add($container->get(SessionMiddleware::class));
$app->add(new ErrorHandler($config->bool('APP_DEBUG', false)));
$app->run();
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\Support\Json;
use Ekdos\Users\Permission;
use Ekdos\Users\UserRepository;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class AuthController
{
public function __construct(
private AuthService $auth,
private SessionCookie $cookie,
private UserRepository $users,
) {}
public function login(Request $request, Response $response): Response
{
$body = $request->getParsedBody();
$body = is_array($body) ? $body : Json::decode((string) $request->getBody());
$result = $this->auth->login(
username: (string) ($body['username'] ?? $body['account'] ?? ''),
password: (string) ($body['password'] ?? ''),
clientIp: self::clientIp($request),
);
if ($result['ok'] === false) {
return Json::error($response, $result['error'], $result['status']);
}
return $this->cookie->attach(Json::write($response, ['user' => self::describe($result['session'])]), $result['session']->id);
}
/**
* Die Auth-Sonde der Oberflaeche.
*
* Bewusst ohne RequireAuth: die SPA fragt hier vor jeder Anzeige an und
* entscheidet anhand von 200 oder 401, ob sie die Anmeldemaske zeigt.
*/
public function me(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::write($response, ['user' => null], 401);
}
return Json::write($response, ['user' => self::describe($session)]);
}
/**
* Haelt die Sitzung am Leben, solange jemand am Bildschirm arbeitet.
* Die Oberflaeche ruft das alle fuenf Minuten auf.
*/
public function refresh(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::write($response, ['user' => null], 401);
}
return Json::write($response, ['user' => self::describe($session)]);
}
public function logout(Request $request, Response $response): Response
{
$this->auth->logout($this->cookie->read($request));
return $this->cookie->clear(Json::write($response, ['ok' => true]));
}
/** Eigenes Passwort aendern. Erlaubt jedem angemeldeten Benutzer. */
public function changeOwnPassword(Request $request, Response $response): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::error($response, 'Bitte erneut anmelden.', 401);
}
$body = $request->getParsedBody();
$body = is_array($body) ? $body : Json::decode((string) $request->getBody());
$current = (string) ($body['currentPassword'] ?? '');
$next = (string) ($body['newPassword'] ?? '');
$user = $this->users->findByUsernameWithHash($session->username);
if ($user === null || !password_verify($current, $user->passwordHash)) {
return Json::error($response, 'Das aktuelle Passwort ist nicht korrekt.', 403);
}
if (($problem = AuthService::rejectWeakPassword($next)) !== null) {
return Json::error($response, $problem, 400);
}
$this->users->update($user->id, ['password_hash' => AuthService::hash($next)]);
$this->users->audit($user->id, $user->displayName, 'password.self', $user->id, $user->username);
// Alle anderen Sitzungen dieses Benutzers verfallen; die eigene bleibt bestehen.
return Json::write($response, ['ok' => true]);
}
/** @return array<string, mixed> */
private static function describe(Session $session): array
{
return [
'id' => $session->userId,
'username' => $session->username,
'name' => $session->displayName,
'role' => $session->role->value,
'roleLabel' => $session->role->label(),
'permissions' => $session->role->permissions(),
'canManageUsers' => $session->can(Permission::UsersManage),
];
}
/**
* Hinter nginx steht die echte Adresse in X-Forwarded-For. Es wird nur der
* erste Eintrag verwendet und nur, wenn der Header ueberhaupt gesetzt ist --
* der Origin ist ausschliesslich ueber den Proxy erreichbar.
*/
private static function clientIp(Request $request): string
{
$forwarded = $request->getHeaderLine('X-Forwarded-For');
if ($forwarded !== '') {
return trim(explode(',', $forwarded)[0]);
}
$server = $request->getServerParams();
return is_string($server['REMOTE_ADDR'] ?? null) ? $server['REMOTE_ADDR'] : 'unbekannt';
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\UserRepository;
use Redis;
/**
* Anmeldung gegen die eigene Benutzertabelle.
*
* Die Bremse haengt an Benutzername und Quell-IP gemeinsam, damit weder ein
* verteilter Angriff auf ein Konto noch ein einzelner Client durchprobieren kann.
*/
final readonly class AuthService
{
public function __construct(
private UserRepository $users,
private SessionStore $sessions,
private Redis $redis,
private string $prefix,
private int $maxAttempts,
private int $window,
) {}
/**
* @return array{ok: true, session: Session}|array{ok: false, error: string, status: int}
*/
public function login(string $username, string $password, string $clientIp): array
{
$username = trim($username);
if ($username === '' || $password === '') {
return ['ok' => false, 'error' => 'Benutzername und Passwort sind erforderlich.', 'status' => 400];
}
if ($this->isThrottled($username, $clientIp)) {
return ['ok' => false, 'error' => 'Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.', 'status' => 429];
}
$user = $this->users->findByUsernameWithHash($username);
// Auch bei unbekanntem Benutzer wird gehasht, damit die Antwortzeit nichts verraet.
$hash = $user?->passwordHash ?? '$argon2id$v=19$m=65536,t=4,p=1$aaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
$valid = password_verify($password, $hash);
if ($user === null || !$valid) {
$this->recordFailure($username, $clientIp);
return ['ok' => false, 'error' => 'Benutzer oder Passwort ist nicht korrekt.', 'status' => 401];
}
if (!$user->isActive) {
$this->recordFailure($username, $clientIp);
return ['ok' => false, 'error' => 'Dieses Konto ist deaktiviert.', 'status' => 403];
}
$this->clearFailures($username, $clientIp);
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
$this->users->update($user->id, ['password_hash' => self::hash($password)]);
}
$this->users->touchLogin($user->id);
return ['ok' => true, 'session' => $this->sessions->create($user)];
}
public function logout(string $sessionId): void
{
if ($sessionId !== '') {
$this->sessions->destroy($sessionId);
}
}
public static function hash(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID);
}
/** Mindestanforderung an ein Passwort. Bewusst schlicht: Laenge schlaegt Zeichenklassen. */
public static function rejectWeakPassword(string $password): ?string
{
if (mb_strlen($password) < 12) {
return 'Das Passwort muss mindestens 12 Zeichen lang sein.';
}
if (mb_strlen($password) > 200) {
return 'Das Passwort darf höchstens 200 Zeichen lang sein.';
}
return null;
}
private function isThrottled(string $username, string $clientIp): bool
{
return (int) ($this->redis->get($this->throttleKey($username, $clientIp)) ?: 0) >= $this->maxAttempts;
}
private function recordFailure(string $username, string $clientIp): void
{
$key = $this->throttleKey($username, $clientIp);
if ((int) $this->redis->incr($key) === 1) {
$this->redis->expire($key, $this->window);
}
}
private function clearFailures(string $username, string $clientIp): void
{
$this->redis->del($this->throttleKey($username, $clientIp));
}
private function throttleKey(string $username, string $clientIp): string
{
return $this->prefix . 'login:' . sha1(strtolower($username) . '|' . $clientIp);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\Role;
/**
* Der serverseitige Teil der Sitzung. Der Browser kennt davon nichts ausser
* der undurchsichtigen Kennung im Cookie.
*/
final readonly class Session
{
public function __construct(
public string $id,
public string $userId,
public string $username,
public string $displayName,
public Role $role,
public int $issuedAt,
public int $lastSeenAt,
) {}
public static function fromArray(string $id, array $data): ?self
{
$role = Role::tryFrom((string) ($data['role'] ?? ''));
if ($role === null || !isset($data['userId'], $data['username'])) {
return null;
}
return new self(
id: $id,
userId: (string) $data['userId'],
username: (string) $data['username'],
displayName: (string) ($data['displayName'] ?? $data['username']),
role: $role,
issuedAt: (int) ($data['issuedAt'] ?? 0),
lastSeenAt: (int) ($data['lastSeenAt'] ?? 0),
);
}
public function toArray(): array
{
return [
'userId' => $this->userId,
'username' => $this->username,
'displayName' => $this->displayName,
'role' => $this->role->value,
'issuedAt' => $this->issuedAt,
'lastSeenAt' => $this->lastSeenAt,
];
}
public function can(string $permission): bool
{
return $this->role->allows($permission);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Das Cookie traegt nur die Sitzungskennung.
*
* Ohne Max-Age bleibt es ein reines Sitzungscookie: schliesst jemand den Browser
* vollstaendig, ist beim naechsten Aufruf wieder eine Anmeldung noetig. Das war
* schon in der Next.js-Fassung so und bleibt bewusst erhalten.
*/
final readonly class SessionCookie
{
public function __construct(
private string $name,
private bool $secure,
) {}
public function read(Request $request): string
{
$value = $request->getCookieParams()[$this->name] ?? '';
return is_string($value) ? $value : '';
}
public function attach(Response $response, string $id): Response
{
return $response->withAddedHeader('Set-Cookie', $this->build($id, null));
}
public function clear(Response $response): Response
{
return $response->withAddedHeader('Set-Cookie', $this->build('', 0));
}
private function build(string $value, ?int $maxAge): string
{
$parts = [
$this->name . '=' . $value,
'Path=/',
'HttpOnly',
'SameSite=Strict',
];
if ($this->secure) {
$parts[] = 'Secure';
}
if ($maxAge !== null) {
$parts[] = 'Max-Age=' . $maxAge;
}
return implode('; ', $parts);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Ekdos\Auth;
use Ekdos\Users\User;
use Redis;
/**
* Serverseitiger Sitzungsspeicher (BFF-Ticket-Store) auf Redis.
*
* Der Browser bekommt ausschliesslich eine zufaellige, undurchsichtige Kennung.
* Rollen, Rechte und Anzeigename bleiben auf dem Server: das Cookie waechst
* nicht mit den Claims mit und ein Reverse Proxy hat nie zu grosse Header.
*
* Schluessel:
* <prefix>sess:<id> Sitzungsdaten, TTL = Leerlauffenster
* <prefix>sess:user:<uid> Menge aller Sitzungen eines Benutzers, fuer Zwangsabmeldung
*/
final readonly class SessionStore
{
public function __construct(
private Redis $redis,
private string $prefix,
private int $ttl,
) {}
public function create(User $user): Session
{
$now = time();
$session = new Session(
id: self::newId(),
userId: $user->id,
username: $user->username,
displayName: $user->displayName,
role: $user->role,
issuedAt: $now,
lastSeenAt: $now,
);
$this->redis->setex($this->key($session->id), $this->ttl, json_encode($session->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->redis->sAdd($this->userKey($user->id), $session->id);
// Der Index darf den laengsten moeglichen Sitzungslauf ueberdauern, nicht laenger.
$this->redis->expire($this->userKey($user->id), $this->ttl * 24);
return $session;
}
public function read(string $id): ?Session
{
if (!self::looksLikeId($id)) {
return null;
}
$raw = $this->redis->get($this->key($id));
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? Session::fromArray($id, $decoded) : null;
}
/**
* Gleitendes Leerlauffenster: jede authentifizierte Anfrage schiebt die TTL nach vorn.
* Der Rumpf wird nur einmal pro Minute neu geschrieben, sonst reicht ein EXPIRE.
*/
public function touch(Session $session): void
{
$now = time();
if ($now - $session->lastSeenAt < 60) {
$this->redis->expire($this->key($session->id), $this->ttl);
return;
}
$refreshed = new Session(
id: $session->id,
userId: $session->userId,
username: $session->username,
displayName: $session->displayName,
role: $session->role,
issuedAt: $session->issuedAt,
lastSeenAt: $now,
);
$this->redis->setex($this->key($session->id), $this->ttl, json_encode($refreshed->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
public function destroy(string $id): void
{
$session = $this->read($id);
if ($session !== null) {
$this->redis->sRem($this->userKey($session->userId), $id);
}
$this->redis->del($this->key($id));
}
/**
* Meldet einen Benutzer ueberall ab. Wird nach Rollenwechsel, Deaktivierung,
* Passwortwechsel und Loeschung aufgerufen, damit eine laufende Sitzung
* keine Rechte behaelt, die der Benutzer nicht mehr hat.
*/
public function destroyAllFor(string $userId): int
{
$ids = $this->redis->sMembers($this->userKey($userId));
if (!is_array($ids) || $ids === []) {
$this->redis->del($this->userKey($userId));
return 0;
}
$this->redis->del(array_map(fn (string $id): string => $this->key($id), $ids));
$this->redis->del($this->userKey($userId));
return count($ids);
}
private static function newId(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
private static function looksLikeId(string $id): bool
{
return $id !== '' && strlen($id) <= 64 && preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1;
}
private function key(string $id): string
{
return $this->prefix . 'sess:' . $id;
}
private function userKey(string $userId): string
{
return $this->prefix . 'sess:user:' . $userId;
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Ekdos\Bootstrap;
use DI\ContainerBuilder;
use Ekdos\Auth\AuthService;
use Ekdos\Auth\SessionCookie;
use Ekdos\Auth\SessionStore;
use Ekdos\N8n\Cache;
use Ekdos\N8n\Client;
use Ekdos\N8n\Endpoints;
use Ekdos\Support\Config;
use Ekdos\Users\UserRepository;
use GuzzleHttp\Client as Guzzle;
use PDO;
use Psr\Container\ContainerInterface;
use Redis;
use function DI\autowire;
use function DI\factory;
use function DI\get;
/**
* Der Kompositionswurzel. Alles, was einen Zustand oder eine Verbindung haelt,
* wird hier genau einmal beschrieben.
*/
final class Container
{
public static function build(string $projectRoot): ContainerInterface
{
$builder = new ContainerBuilder();
$config = self::loadConfig($projectRoot);
if (!$config->bool('APP_DEBUG', false)) {
$builder->enableCompilation($projectRoot . '/var/cache');
}
$builder->addDefinitions([
Config::class => $config,
// ── PostgreSQL ────────────────────────────────────────────────
PDO::class => factory(static function (Config $config): PDO {
return new PDO(
$config->string('DB_DSN', 'pgsql:host=127.0.0.1;port=5432;dbname=ekdos'),
$config->string('DB_USER', 'ekdos'),
$config->string('DB_PASSWORD'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
);
}),
// ── Redis: Sitzungen und n8n-Cache ────────────────────────────
Redis::class => factory(static function (Config $config): Redis {
$redis = new Redis();
$redis->connect($config->string('REDIS_HOST', '127.0.0.1'), $config->int('REDIS_PORT', 6379), 2.0);
if ($config->has('REDIS_PASSWORD')) {
$redis->auth($config->string('REDIS_PASSWORD'));
}
$redis->select($config->int('REDIS_DB', 0));
return $redis;
}),
SessionStore::class => factory(static function (Redis $redis, Config $config): SessionStore {
return new SessionStore($redis, $config->string('REDIS_PREFIX', 'ekdos:'), $config->int('SESSION_TTL', 3600));
}),
SessionCookie::class => factory(static function (Config $config): SessionCookie {
return new SessionCookie($config->string('SESSION_COOKIE', 'ekdos_session'), $config->bool('SESSION_COOKIE_SECURE', true));
}),
AuthService::class => factory(static function (UserRepository $users, SessionStore $sessions, Redis $redis, Config $config): AuthService {
return new AuthService(
users: $users,
sessions: $sessions,
redis: $redis,
prefix: $config->string('REDIS_PREFIX', 'ekdos:'),
maxAttempts: $config->int('LOGIN_MAX_ATTEMPTS', 10),
window: $config->int('LOGIN_WINDOW', 900),
);
}),
Cache::class => factory(static function (Redis $redis, Config $config): Cache {
return new Cache($redis, $config->string('REDIS_PREFIX', 'ekdos:'));
}),
// ── n8n ───────────────────────────────────────────────────────
Endpoints::class => factory(static fn (Config $config): Endpoints => Endpoints::fromConfig($config)),
Guzzle::class => factory(static function (Config $config): Guzzle {
return new Guzzle([
'timeout' => $config->int('N8N_TIMEOUT', 15),
'connect_timeout' => $config->int('N8N_CONNECT_TIMEOUT', 5),
// n8n antwortet gelegentlich mit einer Weiterleitung auf sich selbst.
'allow_redirects' => ['max' => 3],
'headers' => ['User-Agent' => 'EK-DOS-WEB/3.0'],
]);
}),
Client::class => autowire()->constructorParameter('http', get(Guzzle::class)),
UserRepository::class => autowire(),
]);
return $builder->build();
}
/** Liest .env, faellt aber auf echte Umgebungsvariablen zurueck (Container, systemd). */
public static function loadConfig(string $projectRoot): Config
{
$values = getenv();
if (is_readable($projectRoot . '/.env')) {
$dotenv = \Dotenv\Dotenv::createArrayBacked($projectRoot);
$values = $dotenv->load() + $values;
}
return Config::fromEnvironment($values);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Ekdos\Bootstrap;
use Ekdos\Auth\AuthController;
use Ekdos\Http\Middleware\RequireAuth;
use Ekdos\Http\Middleware\RequirePermission;
use Ekdos\Relay\CustomerInvoicesController;
use Ekdos\Relay\CustomersController;
use Ekdos\Relay\HoursController;
use Ekdos\Relay\InternalTasksController;
use Ekdos\Relay\InvoicesController;
use Ekdos\Relay\OffersController;
use Ekdos\Relay\OnlinePurchasesController;
use Ekdos\Relay\TicketsController;
use Ekdos\Support\Json;
use Ekdos\Users\Permission;
use Ekdos\Users\UserController;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;
/**
* Alle Routen der Anwendung.
*
* Anonym erreichbar sind nur die Gesundheitspruefung, die Anmeldung, die
* Auth-Sonde /api/auth/me und der Cache-Hinweis von n8n. Alles andere haengt
* hinter RequireAuth, rechtegebundene Aktionen zusaetzlich hinter RequirePermission.
*/
final class Routes
{
public static function register(App $app): void
{
$app->group('/api', static function (RouteCollectorProxy $api): void {
// ── Anonym ────────────────────────────────────────────────────
// Die Oberflaeche prueft vor der Anmeldung, ob das Backend lebt.
$api->get('/health', static fn (Request $r, Response $w): Response => Json::write($w, ['ok' => true, 'service' => 'ek-dos-web', 'time' => gmdate('c')]));
$api->post('/auth/login', [AuthController::class, 'login']);
$api->get('/auth/me', [AuthController::class, 'me']);
$api->post('/auth/logout', [AuthController::class, 'logout']);
// n8n meldet hierueber eine Ticketerstellung. Traegt entweder eine
// Sitzung oder das geteilte Geheimnis; die Pruefung liegt im Controller.
$api->post('/tickets/refresh', [TicketsController::class, 'refresh']);
// ── Angemeldet ────────────────────────────────────────────────
$api->group('', static function (RouteCollectorProxy $secure): void {
$secure->post('/auth/refresh', [AuthController::class, 'refresh']);
$secure->post('/auth/password', [AuthController::class, 'changeOwnPassword']);
// Tickets
$secure->get('/tickets', [TicketsController::class, 'index']);
$secure->get('/tickets/digitale-akte', [TicketsController::class, 'digitalFile']);
$secure->get('/tickets/servicebericht', [TicketsController::class, 'serviceReport']);
$secure->post('/tickets/consultation', [TicketsController::class, 'consultation'])
->add(new RequirePermission(Permission::InvoicesProcess));
// Angebote
$secure->get('/offers', [OffersController::class, 'index']);
$secure->post('/offers', [OffersController::class, 'update']);
$secure->delete('/offers', [OffersController::class, 'delete'])
->add(new RequirePermission(Permission::OffersDelete));
// Kundenstamm
$secure->get('/customers', [CustomersController::class, 'index']);
$secure->post('/customers', [CustomersController::class, 'create']);
$secure->put('/customers', [CustomersController::class, 'update']);
$secure->delete('/customers', [CustomersController::class, 'delete'])
->add(new RequirePermission(Permission::CustomersDelete));
$secure->get('/customers/digitale-akte', [CustomersController::class, 'digitalFile']);
// Rechnungen eines Kunden und die Gesamtuebersicht
$secure->get('/customer-invoices', [CustomerInvoicesController::class, 'index']);
$secure->post('/customer-invoices', [CustomerInvoicesController::class, 'assign']);
$secure->get('/customer-invoices/pdf', [CustomerInvoicesController::class, 'pdf']);
$secure->post('/customer-invoices/sync', [CustomerInvoicesController::class, 'sync']);
// Rechnungsablauf: anfertigen, pruefen, versenden
$secure->get('/invoices-review', [InvoicesController::class, 'reviewList']);
$secure->group('', static function (RouteCollectorProxy $invoices): void {
$invoices->get('/invoices-create', [InvoicesController::class, 'createList']);
$invoices->put('/invoices-create', [InvoicesController::class, 'completeCreateTask']);
$invoices->get('/invoices-create/digitale-akte', [InvoicesController::class, 'digitalFile']);
$invoices->get('/invoices-send', [InvoicesController::class, 'sendList']);
$invoices->post('/invoices-send', [InvoicesController::class, 'confirmSent']);
})->add(new RequirePermission(Permission::InvoicesProcess));
// Interne Aufgaben
$secure->get('/internal-tasks', [InternalTasksController::class, 'index']);
$secure->post('/internal-tasks', [InternalTasksController::class, 'create']);
$secure->put('/internal-tasks', [InternalTasksController::class, 'complete']);
$secure->patch('/internal-tasks', [InternalTasksController::class, 'edit']);
$secure->delete('/internal-tasks', [InternalTasksController::class, 'delete'])
->add(new RequirePermission(Permission::TasksDelete));
// Online-Kaeufe
$secure->get('/online-purchases', [OnlinePurchasesController::class, 'index']);
$secure->post('/online-purchases', [OnlinePurchasesController::class, 'create'])
->add(new RequirePermission(Permission::PurchasesCreate));
$secure->put('/online-purchases', [OnlinePurchasesController::class, 'complete'])
->add(new RequirePermission(Permission::PurchasesComplete));
$secure->delete('/online-purchases', [OnlinePurchasesController::class, 'delete'])
->add(new RequirePermission(Permission::PurchasesDelete));
// Stundennachweise
$secure->get('/hours', [HoursController::class, 'index']);
// Benutzerverwaltung
$secure->group('/users', static function (RouteCollectorProxy $users): void {
$users->get('', [UserController::class, 'index']);
$users->post('', [UserController::class, 'create']);
$users->get('/audit', [UserController::class, 'audit']);
$users->patch('/{id}', [UserController::class, 'update']);
$users->delete('/{id}', [UserController::class, 'delete']);
})->add(new RequirePermission(Permission::UsersManage));
})->add(new RequireAuth());
});
// Alles unterhalb von /api, was es nicht gibt, antwortet als JSON.
// Statische Dateien beantwortet nginx, sie erreichen PHP nie.
$app->any('/api/{path:.*}', static fn (Request $r, Response $w): Response => Json::error($w, 'Diese Schnittstelle gibt es nicht.', 404));
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Ekdos\Http\Middleware;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Response as Psr7Response;
use Throwable;
/**
* Ausnahmen werden protokolliert, nicht erzaehlt.
*
* Es gibt bewusst kein Info-/Debug-Logging: in das Protokoll gehoert nur, was
* tatsaechlich schiefgegangen ist. error_log() landet im Fehlerkanal von php-fpm
* und damit dort, wo auch nginx hinschreibt.
*
* Soll spaeter Sentry dazukommen, ist genau eine Zeile noetig -- siehe unten.
*/
final readonly class ErrorHandler implements MiddlewareInterface
{
public function __construct(private bool $debug) {}
public function process(Request $request, Handler $handler): Response
{
try {
return $handler->handle($request);
} catch (HttpMethodNotAllowedException) {
return Json::error(new Psr7Response(), 'Diese Methode ist für diese Schnittstelle nicht vorgesehen.', 405);
} catch (HttpNotFoundException) {
// Kein Fehlerfall, sondern ein Tippfehler in der Adresse. Nicht protokollieren.
return Json::error(new Psr7Response(), 'Diese Schnittstelle gibt es nicht.', 404);
} catch (Throwable $exception) {
error_log(sprintf(
'[ekdos] %s %s -- %s: %s @ %s:%d',
$request->getMethod(),
(string) $request->getUri()->getPath(),
$exception::class,
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
));
// Sentry-Einstiegspunkt: \Sentry\captureException($exception);
$message = $this->debug
? $exception::class . ': ' . $exception->getMessage()
: 'Im Backend ist ein unerwarteter Fehler aufgetreten.';
return Json::error(new Psr7Response(), $message, 500);
}
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Ekdos\Http\Middleware;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Psr7\Response as Psr7Response;
/**
* Sauberes 401 statt einer Weiterleitung: die Oberflaeche liest den Status und
* zeigt daraufhin die Anmeldemaske. Es gibt hier nichts umzuleiten, weil der
* Anmeldedialog Teil der SPA ist.
*/
final class RequireAuth implements MiddlewareInterface
{
public function process(Request $request, Handler $handler): Response
{
if (SessionMiddleware::of($request) === null) {
return Json::error(new Psr7Response(), 'Bitte erneut anmelden.', 401);
}
return $handler->handle($request);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Ekdos\Http\Middleware;
use Ekdos\Support\Json;
use Ekdos\Users\Permission;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Slim\Psr7\Response as Psr7Response;
/**
* Ersetzt die frueheren Namensabfragen ("nur Sascha", "nur Svenja") durch ein Recht.
* Die Meldung nennt das fehlende Recht, damit im Buero klar ist, wer helfen kann.
*/
final readonly class RequirePermission implements MiddlewareInterface
{
public function __construct(private string $permission) {}
public function process(Request $request, Handler $handler): Response
{
$session = SessionMiddleware::of($request);
if ($session === null) {
return Json::error(new Psr7Response(), 'Bitte erneut anmelden.', 401);
}
if (!$session->can($this->permission)) {
return Json::error(
new Psr7Response(),
'Dafür fehlt die Berechtigung "' . Permission::label($this->permission) . '".',
403,
);
}
return $handler->handle($request);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Ekdos\Http\Middleware;
use Ekdos\Auth\Session;
use Ekdos\Auth\SessionCookie;
use Ekdos\Auth\SessionStore;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
/**
* Loest das Cookie in eine Sitzung auf und haengt sie an die Anfrage.
* Sie weist nichts ab: das uebernehmen RequireAuth und RequirePermission.
*/
final readonly class SessionMiddleware implements MiddlewareInterface
{
public const string ATTRIBUTE = 'session';
public function __construct(
private SessionStore $sessions,
private SessionCookie $cookie,
) {}
public function process(Request $request, Handler $handler): Response
{
$id = $this->cookie->read($request);
$session = $id === '' ? null : $this->sessions->read($id);
if ($session !== null) {
$this->sessions->touch($session);
}
return $handler->handle($request->withAttribute(self::ATTRIBUTE, $session));
}
public static function of(Request $request): ?Session
{
$session = $request->getAttribute(self::ATTRIBUTE);
return $session instanceof Session ? $session : null;
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
use Redis;
/**
* Kurzlebiger Redis-Cache vor n8n.
*
* Das Buero laesst mehrere Ansichten gleichzeitig offen, und die Ticketliste
* aktualisiert sich alle 20 Sekunden von selbst. Ohne Cache landet jeder dieser
* Takte als eigener Aufruf bei n8n. Drei Mechanismen verhindern das:
*
* 1. Frischer Eintrag Innerhalb der TTL wird ohne Ruecksprache geantwortet.
*
* 2. Single-Flight Laeuft der Cache ab, waehrend drei Ansichten gleichzeitig
* fragen, holt genau eine die Daten. Die anderen warten
* kurz auf deren Ergebnis, statt parallel loszurennen.
*
* 3. Stale-on-Error Neben dem frischen Eintrag liegt eine deutlich laenger
* haltbare Kopie. Ist n8n nicht erreichbar, sieht das Buero
* die letzten bekannten Daten statt einer Fehlerseite.
*
* Schreibende Aufrufe verwerfen ihre Gruppe sofort (invalidate), damit eine
* Aenderung nicht bis zum Ablauf der TTL unsichtbar bleibt.
*
* Schluessel:
* <prefix>n8n:fresh:<hash> die kurzlebige Antwort
* <prefix>n8n:stale:<hash> die Rueckfallkopie
* <prefix>n8n:lock:<hash> Single-Flight-Sperre
* <prefix>n8n:group:<name> Menge aller Schluessel einer Gruppe
*/
final readonly class Cache
{
/** Wie lange ein wartender Aufrufer auf das Ergebnis des Single-Flight hofft. */
private const int WAIT_TOTAL_MS = 400;
private const int WAIT_STEP_MS = 25;
private const int LOCK_TTL_SECONDS = 15;
public function __construct(
private Redis $redis,
private string $prefix,
) {}
/**
* @param string $group Gruppe fuer die gezielte Verwerfung, z. B. "tickets".
* @param string $key Eindeutig fuer diese Abfrage inklusive Parameter.
* @param int $ttl Sekunden, die die Antwort als frisch gilt.
* @param int $staleTtl Sekunden, die die Rueckfallkopie vorgehalten wird.
* @param callable():Reply $fetch
*/
public function remember(string $group, string $key, int $ttl, int $staleTtl, callable $fetch): Reply
{
$hash = sha1($key);
if (($hit = $this->readEntry($this->freshKey($hash))) !== null) {
return $hit;
}
if (!$this->acquireLock($hash)) {
// Jemand anderes holt gerade. Kurz auf dessen Ergebnis warten.
if (($shared = $this->waitForFresh($hash)) !== null) {
return $shared;
}
// Warten hat nichts gebracht: lieber etwas Veraltetes als gar nichts.
if (($stale = $this->readEntry($this->staleKey($hash))) !== null) {
return $stale->asStale();
}
}
try {
$reply = $fetch();
if ($reply->ok()) {
$this->store($group, $hash, $reply, $ttl, $staleTtl);
return $reply;
}
// n8n antwortet, aber mit einem Fehler. Alte Daten schlagen eine Fehlerseite.
return $this->readEntry($this->staleKey($hash))?->asStale() ?? $reply;
} finally {
$this->releaseLock($hash);
}
}
/** Verwirft alle Eintraege der genannten Gruppen. Nach jedem schreibenden Aufruf. */
public function invalidate(string ...$groups): void
{
foreach ($groups as $group) {
$groupKey = $this->groupKey($group);
$members = $this->redis->sMembers($groupKey);
if (is_array($members) && $members !== []) {
$this->redis->del($members);
}
$this->redis->del($groupKey);
}
}
/** Leert alles, was dieser Cache angelegt hat. Nur fuer die Wartungs-CLI. */
public function flushAll(): int
{
$removed = 0;
$pattern = $this->prefix . 'n8n:*';
$cursor = null;
do {
$keys = $this->redis->scan($cursor, $pattern, 500);
if (is_array($keys) && $keys !== []) {
$removed += (int) $this->redis->del($keys);
}
} while ($cursor > 0);
return $removed;
}
private function store(string $group, string $hash, Reply $reply, int $ttl, int $staleTtl): void
{
$payload = json_encode(
['status' => $reply->status, 'body' => $reply->body, 'contentType' => $reply->contentType],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);
$this->redis->setex($this->freshKey($hash), max(1, $ttl), $payload);
$this->redis->setex($this->staleKey($hash), max($ttl, $staleTtl), $payload);
$this->redis->sAdd($this->groupKey($group), $this->freshKey($hash), $this->staleKey($hash));
$this->redis->expire($this->groupKey($group), max($ttl, $staleTtl) + 60);
}
private function readEntry(string $key): ?Reply
{
$raw = $this->redis->get($key);
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded) || !isset($decoded['status'], $decoded['body'])) {
return null;
}
return new Reply((int) $decoded['status'], (string) $decoded['body'], (string) ($decoded['contentType'] ?? ''));
}
private function waitForFresh(string $hash): ?Reply
{
$deadline = microtime(true) + self::WAIT_TOTAL_MS / 1000;
while (microtime(true) < $deadline) {
usleep(self::WAIT_STEP_MS * 1000);
if (($hit = $this->readEntry($this->freshKey($hash))) !== null) {
return $hit;
}
}
return null;
}
private function acquireLock(string $hash): bool
{
return (bool) $this->redis->set($this->lockKey($hash), '1', ['NX', 'EX' => self::LOCK_TTL_SECONDS]);
}
private function releaseLock(string $hash): void
{
$this->redis->del($this->lockKey($hash));
}
private function freshKey(string $hash): string
{
return $this->prefix . 'n8n:fresh:' . $hash;
}
private function staleKey(string $hash): string
{
return $this->prefix . 'n8n:stale:' . $hash;
}
private function lockKey(string $hash): string
{
return $this->prefix . 'n8n:lock:' . $hash;
}
private function groupKey(string $group): string
{
return $this->prefix . 'n8n:group:' . $group;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
/**
* Wie lange welcher Bereich zwischengespeichert wird.
*
* Die frische TTL orientiert sich am Aktualisierungstakt der Oberflaeche: die
* Ticketliste laedt alle 20 Sekunden nach, 15 Sekunden Cache fangen also beide
* offenen Arbeitsplaetze ab, ohne dass jemand veraltete Daten sieht.
*
* Die Rueckfall-TTL ist grosszuegig. Sie greift nur, wenn n8n nicht erreichbar
* ist -- dann sind zehn Minuten alte Tickets deutlich besser als eine Fehlermeldung.
*
* @phpstan-type Policy array{0: string, 1: int, 2: int}
*/
final class CachePolicy
{
// Gruppe frisch Rueckfall
public const array Tickets = ['tickets', 15, 600];
public const array Offers = ['offers', 30, 600];
public const array Customers = ['customers', 120, 1800];
public const array Tasks = ['tasks', 20, 600];
public const array Invoices = ['invoices', 30, 600];
public const array InvoicesCreate = ['invoices-create', 30, 600];
public const array InvoicesReview = ['invoices-review', 30, 600];
public const array InvoicesSend = ['invoices-send', 30, 600];
public const array Purchases = ['purchases', 30, 600];
/** Der Stundennachweis ist ein schwerer Report und aendert sich selten. */
public const array Hours = ['hours', 300, 3600];
/** Alle Gruppen, fuer die Wartungs-CLI und den Sammelaufruf nach dem Abgleich. */
public const array ALL_GROUPS = [
'tickets', 'offers', 'customers', 'tasks',
'invoices', 'invoices-create', 'invoices-review', 'invoices-send',
'purchases', 'hours',
];
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Exception\GuzzleException;
/**
* Duenner HTTP-Zugang zu n8n.
*
* Wirft bei einem HTTP-Fehlerstatus nicht: der Aufrufer entscheidet, was ein
* 404 oder 502 aus n8n fuer die Oberflaeche bedeutet. Nur ein echter
* Verbindungsfehler ergibt Status 0.
*/
final readonly class Client
{
public function __construct(private Guzzle $http) {}
public function get(string $url, array $headers = []): Reply
{
return $this->send('GET', $url, $headers, null);
}
public function post(string $url, ?array $json = null, array $headers = []): Reply
{
return $this->send('POST', $url, $headers, $json);
}
public function put(string $url, ?array $json = null, array $headers = []): Reply
{
return $this->send('PUT', $url, $headers, $json);
}
public function delete(string $url, ?array $json = null, array $headers = []): Reply
{
return $this->send('DELETE', $url, $headers, $json);
}
private function send(string $method, string $url, array $headers, ?array $json): Reply
{
$options = ['headers' => $headers + ['Accept' => 'application/json'], 'http_errors' => false];
if ($json !== null) {
$options['json'] = $json;
}
try {
$response = $this->http->request($method, $url, $options);
return new Reply(
status: $response->getStatusCode(),
body: (string) $response->getBody(),
contentType: $response->getHeaderLine('Content-Type'),
);
} catch (GuzzleException) {
// n8n nicht erreichbar. Kein Stacktrace ins Protokoll: der Aufrufer
// meldet das als 502 und faellt, wo moeglich, auf den Cache zurueck.
return new Reply(status: 0, body: '', contentType: '');
}
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
use Ekdos\Support\Config;
/**
* Alle n8n-Adressen an einer Stelle.
*
* Zwei Wege, bewusst getrennt:
*
* webhook() Der n8n-Dienst auf Port 5678. Er ist ausschliesslich ueber den
* CNAME n8n.elektro-krueger.eu erreichbar, deshalb laeuft jeder
* ek-dos-web-Webhook darueber.
*
* internal() Jeder weitere n8n-Dienst, der auf einem anderen Port lauscht,
* wird ueber die RFC1918-Adresse angesprochen. Diese Dienste sind
* nicht nach aussen veroeffentlicht.
*/
final readonly class Endpoints
{
private const string PREFIX = '/webhook/ek-dos-web';
public function __construct(
private string $publicBase,
private string $internalHost,
) {}
public static function fromConfig(Config $config): self
{
return new self(
publicBase: rtrim($config->string('N8N_PUBLIC_BASE', 'https://n8n.elektro-krueger.eu'), '/'),
internalHost: $config->string('N8N_INTERNAL_HOST', '10.0.11.131'),
);
}
/** Webhook am Hauptdienst (Port 5678, nur ueber den CNAME erreichbar). */
public function webhook(string $path = '', array $query = []): string
{
return $this->publicBase . self::PREFIX . $path . self::query($query);
}
/** Beliebiger weiterer n8n-Dienst im internen Netz. */
public function internal(int $port, string $path, array $query = []): string
{
return 'http://' . $this->internalHost . ':' . $port . $path . self::query($query);
}
// ── Tickets ───────────────────────────────────────────────────────────
public function openTickets(): string
{
return $this->webhook('/offene-tickets');
}
public function ticketConsultation(): string
{
return $this->webhook('/offene-tickets/kundenruecksprache');
}
public function ticketDigitalFile(string $ticket): string
{
return $this->webhook('/tickets/digitale-akte', ['ticket' => $ticket]);
}
public function ticketServiceReport(string $ticket): string
{
return $this->webhook('/tickets/servicebericht', ['ticket' => $ticket]);
}
// ── Angebote ──────────────────────────────────────────────────────────
public function offers(string $action = ''): string
{
return $this->webhook('/angebote' . $action);
}
// ── Kundenstamm ───────────────────────────────────────────────────────
public function customers(): string
{
return $this->webhook('/kundenstamm');
}
public function customerDigitalFile(string $customer): string
{
return $this->webhook('/kundenstamm/digitale-akte', ['customer' => $customer]);
}
// ── Rechnungen ────────────────────────────────────────────────────────
public function invoices(array $query = []): string
{
return $this->webhook('/rechnungen', $query);
}
public function allInvoices(): string
{
return $this->webhook('/rechnungen/alle');
}
public function assignInvoice(): string
{
return $this->webhook('/rechnungen/zuordnen');
}
public function invoicePdf(string $path): string
{
return $this->webhook('/rechnungen/pdf', ['path' => $path]);
}
public function invoiceSync(): string
{
return $this->webhook('/rechnungen/sync');
}
public function invoicesCreate(): string
{
return $this->webhook('/rechnungen-anfertigen');
}
public function invoicesCreateComplete(): string
{
return $this->webhook('/rechnungen-anfertigen/erledigt');
}
public function invoicesReview(): string
{
return $this->webhook('/rechnungen-pruefen');
}
public function invoicesSend(): string
{
return $this->webhook('/rechnungen-versenden');
}
public function digitalFile(string $taskId): string
{
return $this->webhook('/digitale-akte', ['taskId' => $taskId]);
}
// ── Interne Aufgaben ──────────────────────────────────────────────────
public function internalTasks(string $action = ''): string
{
return $this->webhook('/interne-aufgaben' . $action);
}
// ── Online-Kaeufe ─────────────────────────────────────────────────────
public function onlinePurchases(string $action = ''): string
{
return $this->webhook('/online-kaeufe' . $action);
}
// ── Stundennachweise ──────────────────────────────────────────────────
public function hours(): string
{
return $this->webhook('/stundennachweise');
}
private static function query(array $query): string
{
$filtered = array_filter($query, static fn (mixed $value): bool => $value !== null && $value !== '');
return $filtered === [] ? '' : '?' . http_build_query($filtered);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Ekdos\N8n;
use Ekdos\Support\Json;
/** Eine Antwort von n8n, roh und unbewertet. */
final readonly class Reply
{
public function __construct(
public int $status,
public string $body,
public string $contentType = '',
public bool $stale = false,
) {}
public function ok(): bool
{
return $this->status >= 200 && $this->status < 300;
}
/** Status fuer die Oberflaeche: ein Verbindungsfehler wird zu 502. */
public function statusOr(int $fallback = 502): int
{
return $this->status === 0 ? $fallback : $this->status;
}
public function decoded(): array
{
return Json::decode($this->body);
}
/** n8n liefert Listen mal als {"key":[...]}, mal als nacktes Array. */
public function items(string $key): array
{
return Json::listFrom($this->decoded(), $key);
}
public function asStale(): self
{
return new self($this->status, $this->body, $this->contentType, true);
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Rechnungen eines Kunden, die Gesamtuebersicht und die Zuordnung offener Belege.
*/
final readonly class CustomerInvoicesController extends RelayController
{
/** Die Rechnungsablage. Nur darunter darf ein PDF ausgeliefert werden. */
private const string PDF_ROOT = '/files/EK-DOS/40 Rechnungen/';
public function index(Request $request, Response $response): Response
{
$mode = $this->query($request, 'mode');
$wantsAll = $mode === 'all';
$secret = $this->invoiceSyncSecret();
if ($wantsAll && $secret === '') {
return Json::error($response, 'Die geschützte Rechnungsübersicht ist noch nicht eingerichtet.', 503);
}
$url = $wantsAll ? $this->n8nUrl->allInvoices() : $this->n8nUrl->invoices([
'kunde' => $this->query($request, 'kunde'),
'aliases' => $this->query($request, 'aliases'),
'mode' => $mode,
'liegenschaft' => $this->query($request, 'liegenschaft'),
]);
$reply = $this->cachedGet(CachePolicy::Invoices, $url, $wantsAll ? ['x-ekdos-invoice-sync' => $secret] : []);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Rechnungen konnten nicht geladen werden.');
}
$decoded = $reply->decoded();
return Json::write($response, [
'invoices' => is_array($decoded['invoices'] ?? null) ? array_values($decoded['invoices']) : [],
'properties' => is_array($decoded['properties'] ?? null) ? array_values($decoded['properties']) : [],
'stale' => $reply->stale ?: null,
]);
}
/** Einen offenen Beleg einem Kunden zuordnen. */
public function assign(Request $request, Response $response): Response
{
$body = $this->body($request);
$id = self::text($body, 'id');
$customer = self::text($body, 'kunde');
$secret = $this->invoiceSyncSecret();
if ($id === '' || $customer === '') {
return Json::error($response, 'Rechnung und Kunde müssen ausgewählt sein.', 400);
}
if ($secret === '') {
return Json::error($response, 'Die geschützte Rechnungszuordnung ist noch nicht eingerichtet.', 503);
}
$reply = $this->n8n->post(
$this->n8nUrl->assignInvoice(),
['id' => $id, 'kunde' => $customer, 'kundennummer' => self::text($body, 'kundennummer')],
['x-ekdos-invoice-sync' => $secret],
);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Rechnung konnte nicht zugeordnet werden.');
}
$this->cache->invalidate(CachePolicy::Invoices[0]);
return Json::passthrough($response, $reply->body);
}
/**
* Rechnungs-PDF.
*
* Der Pfad kommt aus der Oberflaeche, deshalb wird er streng geprueft: er muss
* unterhalb der Rechnungsablage liegen, auf .pdf enden und darf kein ".."
* enthalten. Ohne diese Pruefung waere die Route ein Dateibrowser fuer die NAS.
*/
public function pdf(Request $request, Response $response): Response
{
$path = $this->query($request, 'path');
if (!str_starts_with($path, self::PDF_ROOT) || !str_ends_with(strtolower($path), '.pdf') || str_contains($path, '..')) {
return Json::error($response, 'Ungültiger Rechnungspfad.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->invoicePdf($path));
if (!$reply->ok() || $reply->body === '') {
return $this->upstreamError($response, $reply, 'Die Rechnung ist nicht verfügbar.');
}
return $this->pdfResponse($response, $reply->body, 'Rechnung.pdf');
}
/** Stoesst den Abgleich der Rechnungsablage in n8n an. */
public function sync(Request $request, Response $response): Response
{
$secret = $this->invoiceSyncSecret();
if ($secret === '') {
return Json::error($response, 'Der geschützte Rechnungsabgleich ist noch nicht eingerichtet.', 503);
}
$reply = $this->n8n->post($this->n8nUrl->invoiceSync(), null, ['x-ekdos-invoice-sync' => $secret]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Rechnungsabgleich konnte nicht durchgeführt werden.');
}
// Nach einem Abgleich stimmt keine der Rechnungslisten mehr.
$this->cache->invalidate(
CachePolicy::Invoices[0],
CachePolicy::InvoicesCreate[0],
CachePolicy::InvoicesReview[0],
CachePolicy::InvoicesSend[0],
);
return $response->withStatus(204);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class CustomersController extends RelayController
{
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Customers, $this->n8nUrl->customers(), $this->readHeaders());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Kundenstamm konnte nicht aus n8n geladen werden.');
}
return $this->listResponse($response, $reply, 'customers', 'customers');
}
/** Neuanlage. n8n verlangt dafuer das geteilte Geheimnis, nicht den Lese-Schluessel. */
public function create(Request $request, Response $response): Response
{
$secret = $this->invoiceSyncSecret();
if ($secret === '') {
return Json::error($response, 'Die sichere Kundenanlage ist noch nicht eingerichtet.', 503);
}
$reply = $this->n8n->post($this->n8nUrl->customers(), $this->body($request), ['x-ekdos-invoice-sync' => $secret]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht im Kundenstamm gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Customers[0]);
return Json::write($response, ['customer' => $reply->decoded()], 201);
}
public function update(Request $request, Response $response): Response
{
$reply = $this->n8n->put($this->n8nUrl->customers(), $this->body($request), $this->readHeaders());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht im Kundenstamm gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Customers[0]);
return Json::write($response, ['customer' => $reply->decoded()]);
}
/** Vormals: nur Sascha. */
public function delete(Request $request, Response $response): Response
{
$reply = $this->n8n->delete($this->n8nUrl->customers(), $this->body($request), $this->readHeaders());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht aus dem Kundenstamm gelöscht werden.');
}
$this->cache->invalidate(CachePolicy::Customers[0]);
return Json::write($response, ['deleted' => true]);
}
public function digitalFile(Request $request, Response $response): Response
{
$customer = $this->query($request, 'customer');
if ($customer === '' || mb_strlen($customer) > 160) {
return Json::error($response, 'Der Kunde fehlt.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->customerDigitalFile($customer));
if (!$reply->ok() || $reply->body === '') {
return $this->upstreamError($response, $reply, 'Die Digitale Akte ist nicht verfügbar.');
}
return $this->pdfResponse($response, $reply->body, 'Digitale_Akte.pdf');
}
/**
* Der Lese-Schluessel des Kundenstamm-Workflows.
*
* Er stand frueher als Literal im Quelltext; jetzt kommt er aus der Umgebung,
* damit er rotiert werden kann, ohne die Anwendung neu zu bauen.
*/
private function readHeaders(): array
{
$key = $this->config->string('N8N_CUSTOMER_KEY');
return $key === '' ? [] : ['X-EK-DOS-Customer-Key' => $key];
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Stundennachweise.
*
* Ein schwerer Report, den n8n aus mehreren Quellen zusammensetzt. Er wird
* deshalb am laengsten zwischengespeichert (5 Minuten frisch, 1 Stunde Rueckfall).
*/
final readonly class HoursController extends RelayController
{
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Hours, $this->n8nUrl->hours());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Stundennachweise konnten nicht aus n8n geladen werden.');
}
$decoded = $reply->decoded();
// n8n liefert den Report je nach Workflow-Zweig als Objekt oder als Liste mit einem Element.
$source = array_is_list($decoded) ? (is_array($decoded[0] ?? null) ? $decoded[0] : []) : $decoded;
$employees = [];
foreach ((is_array($source['employees'] ?? null) ? $source['employees'] : []) as $row) {
if (is_array($row) && ($employee = self::mapEmployee($row)) !== null) {
$employees[] = $employee;
}
}
$payload = [
'month' => self::text($source, 'month'),
'year' => self::number($source, 'year'),
'generatedAt' => self::text($source, 'generatedAt') !== '' ? self::text($source, 'generatedAt') : gmdate('c'),
'employees' => $employees,
'statusPriority' => self::text($source, 'statusPriority') !== '' ? self::text($source, 'statusPriority') : null,
];
if ($reply->stale) {
$payload['stale'] = true;
}
return Json::write($response, $payload);
}
/** @return array<string, mixed>|null */
private static function mapEmployee(array $row): ?array
{
$name = self::text($row, 'name');
if ($name === '') {
return null;
}
$entries = [];
foreach ((is_array($row['entries'] ?? null) ? $row['entries'] : []) as $entry) {
if (is_array($entry) && !array_is_list($entry)) {
$entries[] = self::mapEntry($entry);
}
}
return [
'name' => $name,
'totalHours' => self::number($row, 'totalHours'),
'entries' => $entries,
'workDaysYear' => self::number($row, 'workDaysYear'),
];
}
/** @return array<string, mixed> */
private static function mapEntry(array $entry): array
{
$date = self::text($entry, 'date');
$label = self::text($entry, 'label');
$status = self::text($entry, 'status');
return [
'date' => $date,
'label' => $label !== '' ? $label : $date,
'status' => $status !== '' ? $status : 'Betrieb',
'customer' => self::optional($entry, 'customer'),
'property' => self::optional($entry, 'property'),
'report' => self::optional($entry, 'report'),
'hours' => self::number($entry, 'hours'),
];
}
private static function number(array $source, string $key): float
{
$value = $source[$key] ?? null;
return is_numeric($value) ? (float) $value : 0.0;
}
private static function optional(array $source, string $key): ?string
{
$value = self::text($source, $key);
return $value !== '' ? $value : null;
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class InternalTasksController extends RelayController
{
private const array RECIPIENTS = ['sascha', 'svenja'];
private const array CATEGORIES = [
'angebote',
'steuerberater',
'kundenruecksprache',
'interne_bueroaufgaben',
'heute_erledigen',
];
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Tasks, $this->n8nUrl->internalTasks());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Interne Aufgaben konnten nicht geladen werden.');
}
return $this->listResponse($response, $reply, 'tasks', 'tasks');
}
public function create(Request $request, Response $response): Response
{
$body = $this->body($request);
$fields = self::validate($body);
if (is_string($fields)) {
return Json::error($response, $fields, 400);
}
$fields['erstellt_von'] = $this->session($request)->displayName;
$reply = $this->n8n->post($this->n8nUrl->internalTasks(), $fields);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Tasks[0]);
return Json::passthrough($response, $reply->body, 201);
}
public function complete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Aufgabe fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/erledigt'), [
'id' => $id,
'erledigt_von' => $this->session($request)->displayName,
]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht abgeschlossen werden.');
}
$this->cache->invalidate(CachePolicy::Tasks[0]);
return Json::passthrough($response, $reply->body);
}
public function edit(Request $request, Response $response): Response
{
$body = $this->body($request);
$id = self::text($body, 'id');
$fields = self::validate($body);
if ($id === '') {
return Json::error($response, 'Die Aufgabe fehlt.', 400);
}
if (is_string($fields)) {
return Json::error($response, 'Bitte Aufgabe, Empfänger und Kategorie vollständig angeben.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/bearbeiten'), ['id' => $id] + $fields);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht bearbeitet werden.');
}
$this->cache->invalidate(CachePolicy::Tasks[0]);
return Json::passthrough($response, $reply->body);
}
/** Vormals: nur Sascha. */
public function delete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Aufgabe fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/loeschen'), ['id' => $id]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht gelöscht werden.');
}
$this->cache->invalidate(CachePolicy::Tasks[0]);
return Json::passthrough($response, $reply->body);
}
/**
* @return array<string, string>|string Felder oder die Fehlermeldung.
*/
private static function validate(array $body): array|string
{
$task = self::text($body, 'aufgabe');
$recipient = self::text($body, 'empfaenger');
$category = self::text($body, 'kategorie');
if ($task === '') {
return 'Bitte Aufgabe eingeben.';
}
if (!in_array($recipient, self::RECIPIENTS, true) || !in_array($category, self::CATEGORIES, true)) {
return 'Bitte Empfänger und Kategorie auswählen.';
}
return [
'aufgabe' => $task,
'empfaenger' => $recipient,
'kategorie' => $category,
'kunden_id' => self::text($body, 'kunden_id'),
];
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Die drei Rechnungslisten des Buero-Ablaufs: anfertigen, pruefen, versenden.
*
* Anfertigen und Versenden waren frueher auf Svenja verdrahtet und haengen jetzt
* am Recht "Rechnungen bearbeiten und versenden". Die Pruefliste war in der
* Next.js-Fassung versehentlich voellig ungeschuetzt -- sie verlangt jetzt wie
* jede andere Route eine angemeldete Sitzung.
*/
final readonly class InvoicesController extends RelayController
{
public function createList(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::InvoicesCreate, $this->n8nUrl->invoicesCreate());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Rechnungsaufgaben-Schnittstelle ist nicht erreichbar.');
}
return Json::passthrough($response, $reply->body);
}
public function completeCreateTask(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Rechnungsaufgabe fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->invoicesCreateComplete(), ['id' => $id]);
$decoded = $reply->decoded();
// n8n meldet einen fachlichen Fehler auch mit HTTP 200 und ok:false.
if (!$reply->ok() || ($decoded['ok'] ?? null) === false) {
$message = self::text($decoded, 'error');
return Json::error($response, $message !== '' ? $message : 'Die Rechnungsaufgabe konnte nicht abgeschlossen werden.', 502);
}
$this->cache->invalidate(CachePolicy::InvoicesCreate[0], CachePolicy::InvoicesReview[0]);
return Json::passthrough($response, $reply->body);
}
public function reviewList(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::InvoicesReview, $this->n8nUrl->invoicesReview());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Prüflisten-Schnittstelle ist nicht erreichbar.');
}
return Json::passthrough($response, $reply->body);
}
public function sendList(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::InvoicesSend, $this->n8nUrl->invoicesSend());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Versandaufgaben-Schnittstelle ist nicht erreichbar.');
}
return Json::passthrough($response, $reply->body);
}
public function confirmSent(Request $request, Response $response): Response
{
$body = $this->body($request);
$id = self::text($body, 'id');
if ($id === '') {
return Json::error($response, 'Die Versandaufgabe fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->invoicesSend(), $body);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Versand konnte nicht bestätigt werden.');
}
$this->cache->invalidate(CachePolicy::InvoicesSend[0], CachePolicy::Invoices[0]);
return Json::passthrough($response, $reply->body);
}
/**
* Die Digitale Akte zu einer Rechnungsaufgabe.
* n8n liefert sie hier als base64 in einem JSON-Feld, nicht als Binaerrumpf.
*/
public function digitalFile(Request $request, Response $response): Response
{
$taskId = $this->query($request, 'taskId');
if ($taskId === '') {
return Json::error($response, 'Die Rechnungsaufgabe fehlt.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->digitalFile($taskId));
$decoded = $reply->decoded();
$contentBytes = self::text($decoded, 'contentBytes');
if (!$reply->ok() || $contentBytes === '') {
$message = self::text($decoded, 'error');
return Json::error(
$response,
$message !== '' ? $message : 'Die Digitale Akte ist nicht verfügbar (HTTP ' . $reply->statusOr(502) . ').',
502,
);
}
$bytes = base64_decode($contentBytes, true);
if ($bytes === false) {
return Json::error($response, 'Die Digitale Akte konnte nicht gelesen werden.', 502);
}
$name = self::text($decoded, 'name');
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name !== '' ? $name : 'Digitale_Akte.pdf') ?? 'Digitale_Akte.pdf';
return $this->pdfResponse($response, $bytes, $filename);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class OffersController extends RelayController
{
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Offers, $this->n8nUrl->offers());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Angebote konnten nicht aus n8n geladen werden.');
}
return $this->listResponse($response, $reply, 'offers', 'offers');
}
/** Versandstatus, Beauftragung und Ruecksetzung laufen ueber dieselbe Route. */
public function update(Request $request, Response $response): Response
{
$body = $this->body($request);
$id = self::text($body, 'id');
$action = self::text($body, 'action');
if ($id === '') {
return Json::error($response, 'Das Angebot fehlt.', 400);
}
return match ($action) {
'versendet' => $this->markSent($response, $body, $id),
'beauftragt' => $this->markCommissioned($response, $body, $id),
'zuruecksetzen' => $this->reset($response, $id),
default => Json::error($response, 'Unbekannte Angebotsaktion.', 400),
};
}
/** Vormals: nur Sascha. */
public function delete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Das Angebot fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->offers('/loeschen'), ['id' => $id]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Das Angebot konnte nicht gelöscht werden.');
}
$this->cache->invalidate(CachePolicy::Offers[0]);
return Json::passthrough($response, $reply->body);
}
private function markSent(Response $response, array $body, string $id): Response
{
$shipping = self::text($body, 'versandart');
if ($shipping !== 'E-Mail' && $shipping !== 'Post') {
return Json::error($response, 'Bitte E-Mail oder Post auswählen.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->offers('/versendet'), ['id' => $id, 'versandart' => $shipping]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Versandstatus konnte nicht gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Offers[0]);
return Json::passthrough($response, $reply->body);
}
private function markCommissioned(Response $response, array $body, string $id): Response
{
$reply = $this->n8n->post($this->n8nUrl->offers('/beauftragt'), [
'id' => $id,
'angebot_nummer' => self::text($body, 'angebot_nummer'),
'kunde' => self::text($body, 'kunde'),
'objekt_zusatz' => self::text($body, 'objekt_zusatz'),
]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Beauftragung konnte nicht gespeichert werden.');
}
// Eine Beauftragung erzeugt in n8n eine Rechnungsaufgabe, deshalb faellt
// auch der Rechnungs-Cache.
$this->cache->invalidate(CachePolicy::Offers[0], CachePolicy::InvoicesCreate[0]);
return Json::passthrough($response, $reply->body);
}
/** Geschuetzte Ruecksetzung: n8n verlangt hier das geteilte Geheimnis. */
private function reset(Response $response, string $id): Response
{
$secret = $this->invoiceSyncSecret();
if ($secret === '') {
return Json::error($response, 'Die geschützte Rücksetzung ist noch nicht eingerichtet.', 503);
}
$reply = $this->n8n->post($this->n8nUrl->offers('/zuruecksetzen'), ['id' => $id], ['x-ekdos-invoice-sync' => $secret]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Versandstatus konnte nicht zurückgesetzt werden.');
}
$this->cache->invalidate(CachePolicy::Offers[0]);
return Json::passthrough($response, $reply->body);
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class OnlinePurchasesController extends RelayController
{
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Purchases, $this->n8nUrl->onlinePurchases());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Die Online-Käufe konnten nicht aus n8n geladen werden.');
}
return $this->listResponse($response, $reply, 'purchases', 'purchases');
}
/** Vormals: nur Sascha. */
public function create(Request $request, Response $response): Response
{
$body = $this->body($request);
$article = self::text($body, 'artikel');
$marketplace = self::text($body, 'kaufort');
$date = self::text($body, 'kaufdatum');
if ($article === '' || $marketplace === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return Json::error($response, 'Artikel, Marktplatz und ein gültiges Kaufdatum sind erforderlich.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases(), ['artikel' => $article, 'kaufort' => $marketplace, 'kaufdatum' => $date]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht in n8n gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Purchases[0]);
return Json::write($response, ['purchase' => $reply->decoded()], 201);
}
/** Vormals: nur Svenja. */
public function complete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Kaufposition fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases('/erledigt'), ['id' => $id]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht abgeschlossen werden.');
}
$this->cache->invalidate(CachePolicy::Purchases[0]);
return Json::write($response, $reply->decoded() + ['erledigt_am' => gmdate('c')]);
}
/** Vormals: nur Sascha. */
public function delete(Request $request, Response $response): Response
{
$id = self::text($this->body($request), 'id');
if ($id === '') {
return Json::error($response, 'Die Kaufposition fehlt.', 400);
}
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases('/loeschen'), ['id' => $id]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht gelöscht werden.');
}
$this->cache->invalidate(CachePolicy::Purchases[0]);
return Json::passthrough($response, $reply->body);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\Auth\Session;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\N8n\Cache;
use Ekdos\N8n\Client;
use Ekdos\N8n\Endpoints;
use Ekdos\N8n\Reply;
use Ekdos\Support\Config;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Gemeinsame Basis aller n8n-Weiterleitungen.
*
* Jeder abgeleitete Controller bleibt damit auf dem, was ihn ausmacht: welche
* Adresse, welche Gruppe, welche Meldung im Fehlerfall.
*/
abstract readonly class RelayController
{
public function __construct(
protected Client $n8n,
protected Endpoints $n8nUrl,
protected Cache $cache,
protected Config $config,
) {}
/**
* Holt eine Liste durch den Cache.
*
* @param array{0: string, 1: int, 2: int} $policy siehe CachePolicy
*/
protected function cachedGet(array $policy, string $url, array $headers = []): Reply
{
[$group, $ttl, $stale] = $policy;
return $this->cache->remember(
$group,
$url . '|' . json_encode($headers, JSON_THROW_ON_ERROR),
$ttl,
$stale,
fn (): Reply => $this->n8n->get($url, $headers),
);
}
protected function session(Request $request): Session
{
// RequireAuth laeuft vor jedem Controller, deshalb ist die Sitzung hier gesetzt.
return SessionMiddleware::of($request) ?? throw new \LogicException('Route ohne RequireAuth erreicht.');
}
protected function body(Request $request): array
{
$parsed = $request->getParsedBody();
if (is_array($parsed)) {
return $parsed;
}
return Json::decode((string) $request->getBody());
}
protected function query(Request $request, string $key, string $fallback = ''): string
{
$value = $request->getQueryParams()[$key] ?? null;
return is_string($value) ? trim($value) : $fallback;
}
/** Nimmt Zeichenketten und Zahlen an und liefert immer eine getrimmte Zeichenkette. */
protected static function text(array $source, string $key): string
{
$value = $source[$key] ?? null;
return is_string($value) || is_int($value) || is_float($value) ? trim((string) $value) : '';
}
/**
* Einheitliche Fehlerantwort fuer einen fehlgeschlagenen n8n-Aufruf.
* Ein Verbindungsfehler (Status 0) wird zu 502.
*/
protected function upstreamError(Response $response, Reply $reply, string $message): Response
{
return Json::error($response, $message, $reply->statusOr(502));
}
/** Antwortet mit einer Liste und markiert, wenn sie aus dem Rueckfall-Cache stammt. */
protected function listResponse(Response $response, Reply $reply, string $key, string $as): Response
{
$payload = [$as => $reply->items($key), 'refreshedAt' => gmdate('c')];
if ($reply->stale) {
$payload['stale'] = true;
$payload['notice'] = 'n8n ist gerade nicht erreichbar. Angezeigt werden die zuletzt bekannten Daten.';
}
return Json::write($response, $payload);
}
/** Reicht eine Binaerantwort (PDF) unveraendert durch. */
protected function pdfResponse(Response $response, string $bytes, string $filename): Response
{
$response->getBody()->write($bytes);
return $response->withHeader('Content-Type', 'application/pdf')
->withHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->withHeader('Cache-Control', 'no-store');
}
/** Geteiltes Geheimnis fuer die geschuetzten n8n-Workflows. */
protected function invoiceSyncSecret(): string
{
return $this->config->string('N8N_INVOICE_SYNC_SECRET');
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Ekdos\Relay;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\N8n\CachePolicy;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final readonly class TicketsController extends RelayController
{
/** Ticketnummern haben die Form 2026-021. */
private const string TICKET_PATTERN = '/^20\d{2}-\d{3}$/';
/**
* Die offene Ticketliste. n8n liefert deutsche Feldnamen, die Oberflaeche
* erwartet camelCase -- diese Abbildung ist die einzige echte Logik im Relay.
*/
public function index(Request $request, Response $response): Response
{
$reply = $this->cachedGet(CachePolicy::Tickets, $this->n8nUrl->openTickets());
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Offene Tickets konnten nicht aus n8n geladen werden.');
}
$tickets = [];
foreach ($reply->items('tickets') as $row) {
if (!is_array($row)) {
continue;
}
$ticket = self::mapTicket($row);
// Zeilen ohne Ticketnummer sind fuer die Ansicht wertlos.
if ($ticket['id'] !== '') {
$tickets[] = $ticket;
}
}
$payload = ['tickets' => $tickets, 'refreshedAt' => gmdate('c')];
if ($reply->stale) {
$payload['stale'] = true;
$payload['notice'] = 'n8n ist gerade nicht erreichbar. Angezeigt werden die zuletzt bekannten Tickets.';
}
return Json::write($response, $payload);
}
/**
* Wird von Workflow4A nach einer Ticketerstellung aufgerufen.
*
* Anders als frueher ist das kein Platzhalter mehr: der Aufruf verwirft den
* Ticket-Cache, sodass die naechste Abfrage der Oberflaeche garantiert die
* neuen Daten sieht statt auf den TTL-Ablauf zu warten.
*/
public function refresh(Request $request, Response $response): Response
{
$secret = $this->config->string('N8N_REFRESH_SECRET');
// Entweder eine angemeldete Sitzung oder das geteilte Geheimnis aus n8n.
if (SessionMiddleware::of($request) === null) {
$presented = $request->getHeaderLine('x-ekdos-webhook-secret');
if ($secret === '' || !hash_equals($secret, $presented)) {
return Json::error($response, 'Bitte erneut anmelden.', 401);
}
}
$this->cache->invalidate(CachePolicy::Tickets[0]);
return Json::write($response, ['ok' => true, 'refreshedAt' => gmdate('c')]);
}
/** Ergebnis der Kundenruecksprache festhalten. Vormals: nur Svenja. */
public function consultation(Request $request, Response $response): Response
{
$body = $this->body($request);
$ticketNumber = self::text($body, 'ticketnummer');
$result = self::text($body, 'ergebnis');
if ($ticketNumber === '' || $result === '') {
return Json::error($response, 'Ticketnummer und Ergebnis sind erforderlich.', 400);
}
if (mb_strlen($result) > 4000) {
return Json::error($response, 'Das Ergebnis darf maximal 4.000 Zeichen enthalten.', 400);
}
// Vorher pruefen, ob schon ein Ergebnis hinterlegt ist. Bewusst ungecacht:
// hier zaehlt der aktuelle Stand, nicht ein 15 Sekunden alter.
$lookup = $this->n8n->get($this->n8nUrl->openTickets());
foreach ($lookup->items('tickets') as $row) {
if (!is_array($row)) {
continue;
}
$id = self::text($row, 'ticketnummer') !== '' ? self::text($row, 'ticketnummer') : self::text($row, 'id');
if ($id === $ticketNumber && self::text($row, 'kundenruecksprache_ergebnis') !== '') {
return Json::error($response, 'Für dieses Ticket wurde bereits ein Ergebnis gespeichert.', 409);
}
}
$reply = $this->n8n->post($this->n8nUrl->ticketConsultation(), ['ticketnummer' => $ticketNumber, 'ergebnis' => $result]);
if (!$reply->ok()) {
return $this->upstreamError($response, $reply, 'Das Ergebnis der Kundenrücksprache konnte nicht in n8n gespeichert werden.');
}
$this->cache->invalidate(CachePolicy::Tickets[0]);
return Json::write($response, ['ok' => true, 'updatedAt' => gmdate('c')]);
}
public function digitalFile(Request $request, Response $response): Response
{
$ticket = $this->query($request, 'ticket');
if (preg_match(self::TICKET_PATTERN, $ticket) !== 1) {
return Json::error($response, 'Die Ticketnummer ist ungültig.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->ticketDigitalFile($ticket));
if (!$reply->ok() || $reply->body === '') {
return $this->upstreamError($response, $reply, 'Die Digitale Akte ist nicht verfügbar.');
}
return $this->pdfResponse($response, $reply->body, 'Digitale_Akte.pdf');
}
public function serviceReport(Request $request, Response $response): Response
{
$ticket = $this->query($request, 'ticket');
if (preg_match(self::TICKET_PATTERN, $ticket) !== 1) {
return Json::error($response, 'Die Ticketnummer ist ungültig.', 400);
}
$reply = $this->n8n->get($this->n8nUrl->ticketServiceReport($ticket));
if (!$reply->ok() || $reply->body === '') {
return $this->upstreamError($response, $reply, 'Der letzte Servicebericht ist nicht verfügbar.');
}
return $this->pdfResponse($response, $reply->body, 'Letzter_Servicebericht.pdf');
}
/** @return array<string, mixed> */
private static function mapTicket(array $row): array
{
$continuation = self::text($row, 'fortsetzung');
$decisionCode = self::text($row, 'entscheidung_code');
return [
'id' => self::firstOf($row, ['ticketnummer', 'id'], ''),
'status' => self::firstOf($row, ['status'], 'offen'),
'reportStatus' => self::optional($row, 'letzter_servicebericht_status'),
'customer' => self::firstOf($row, ['kunde', 'customer'], ''),
'property' => self::firstOf($row, ['liegenschaft', 'property'], ''),
'title' => self::firstOf($row, ['title'], ''),
'updatedAt' => self::firstOf($row, ['updatedAt', 'updated_at', 'erstellt_am', 'createdAt'], ''),
'continuation' => $continuation !== '' ? $continuation : null,
'decisionCode' => $decisionCode !== '' ? $decisionCode : null,
'requiresCustomerConsultation' => self::needsConsultation($decisionCode, $continuation),
'consultationResult' => self::optional($row, 'kundenruecksprache_ergebnis'),
'consultationUpdatedAt' => self::optional($row, 'kundenruecksprache_am'),
'consultationBy' => self::optional($row, 'kundenruecksprache_von'),
];
}
/**
* Ein Ticket braucht Ruecksprache, wenn n8n es entweder ausdruecklich so
* kennzeichnet oder der Fortsetzungstext des Technikers es so formuliert.
*/
private static function needsConsultation(string $decisionCode, string $continuation): bool
{
if ($decisionCode === 'TEILERLEDIGUNG_RUECKSPRACHE_KUNDE') {
return true;
}
// Der Text kommt aus einem PDF und traegt gelegentlich zusammengesetzte
// Umlaute, deshalb vor dem Vergleich nach NFKC normalisieren.
$normalized = class_exists(\Normalizer::class)
? (\Normalizer::normalize($continuation, \Normalizer::FORM_KC) ?: $continuation)
: $continuation;
return preg_match('/nimmt\s+mit\s+kunden\s+für\s+das\s+weitere\s+vorgehen\s+kontakt\s+auf/iu', $normalized) === 1;
}
/** Erstes gesetztes Feld aus der Liste, sonst der Vorgabewert. */
private static function firstOf(array $row, array $keys, string $fallback): string
{
foreach ($keys as $key) {
if (isset($row[$key]) && (is_string($row[$key]) || is_numeric($row[$key]))) {
return (string) $row[$key];
}
}
return $fallback;
}
/** Leere Felder werden zu null, damit die Oberflaeche sie ueberspringen kann. */
private static function optional(array $row, string $key): ?string
{
$value = self::text($row, $key);
return $value !== '' ? $value : null;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Ekdos\Support;
/** Die gelesene Umgebung, einmal beim Start eingesammelt. */
final readonly class Config
{
private function __construct(private array $values) {}
public static function fromEnvironment(array $environment): self
{
return new self($environment);
}
public function string(string $key, string $fallback = ''): string
{
$value = $this->values[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : $fallback;
}
public function int(string $key, int $fallback): int
{
$value = $this->values[$key] ?? null;
return is_numeric($value) ? (int) $value : $fallback;
}
public function bool(string $key, bool $fallback): bool
{
$value = $this->values[$key] ?? null;
if (is_bool($value)) {
return $value;
}
return match (is_string($value) ? strtolower(trim($value)) : null) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => $fallback,
};
}
/** true, sobald ein Wert (z. B. ein geteiltes Geheimnis) hinterlegt ist. */
public function has(string $key): bool
{
return $this->string($key) !== '';
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Ekdos\Support;
use Psr\Http\Message\ResponseInterface as Response;
/** Einheitliche JSON-Antworten. Alle Meldungen sind deutschsprachig und fuer die Oberflaeche gedacht. */
final class Json
{
public static function write(Response $response, mixed $payload, int $status = 200): Response
{
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$response->getBody()->write($body);
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')
->withHeader('Cache-Control', 'no-store')
->withStatus($status);
}
public static function error(Response $response, string $message, int $status): Response
{
return self::write($response, ['error' => $message], $status);
}
/** Schreibt einen bereits von n8n gelieferten JSON-Rumpf unveraendert durch. */
public static function passthrough(Response $response, string $body, int $status = 200): Response
{
$response->getBody()->write($body === '' ? '{}' : $body);
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')
->withHeader('Cache-Control', 'no-store')
->withStatus($status);
}
/** Dekodiert einen n8n-Rumpf defensiv: kaputtes JSON wird zu einem leeren Array. */
public static function decode(string $body): array
{
$decoded = json_decode($body, true);
return is_array($decoded) ? $decoded : [];
}
/**
* n8n liefert Listen mal als {"tickets":[...]}, mal als nacktes Array.
* Diese Helferin nimmt beide Formen an.
*/
public static function listFrom(array $decoded, string $key): array
{
if (isset($decoded[$key]) && is_array($decoded[$key])) {
return array_values($decoded[$key]);
}
return array_is_list($decoded) ? $decoded : [];
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
/**
* Feingranulare Rechte statt Namensabfragen. Jede Prüfung im alten Code
* ("nur Sascha", "nur Svenja") entspricht genau einem Eintrag hier.
*/
final class Permission
{
/** Benutzer anlegen, ändern, löschen. */
public const string UsersManage = 'users.manage';
/** Vormals: nur Sascha. */
public const string OffersDelete = 'offers.delete';
public const string CustomersDelete = 'customers.delete';
public const string TasksDelete = 'tasks.delete';
public const string PurchasesCreate = 'purchases.create';
public const string PurchasesDelete = 'purchases.delete';
/** Vormals: nur Svenja. */
public const string PurchasesComplete = 'purchases.complete';
public const string InvoicesProcess = 'invoices.process';
/** @var list<string> */
public const array ALL = [
self::UsersManage,
self::OffersDelete,
self::CustomersDelete,
self::TasksDelete,
self::PurchasesCreate,
self::PurchasesDelete,
self::PurchasesComplete,
self::InvoicesProcess,
];
/** Menschenlesbare Bezeichnung für die Benutzerverwaltung. */
public static function label(string $permission): string
{
return match ($permission) {
self::UsersManage => 'Benutzerverwaltung',
self::OffersDelete => 'Angebote löschen',
self::CustomersDelete => 'Kunden löschen',
self::TasksDelete => 'Aufgaben löschen',
self::PurchasesCreate => 'Online-Käufe eintragen',
self::PurchasesDelete => 'Online-Käufe löschen',
self::PurchasesComplete => 'Online-Käufe abschließen',
self::InvoicesProcess => 'Rechnungen bearbeiten und versenden',
default => $permission,
};
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
/**
* Die drei Rollen bilden ab, was vorher fest auf die Namen "sascha" und "svenja"
* verdrahtet war. "inhaber" entspricht Saschas Rechten, "buero" denen von Svenja.
*/
enum Role: string
{
case Admin = 'admin';
case Inhaber = 'inhaber';
case Buero = 'buero';
public function label(): string
{
return match ($this) {
self::Admin => 'Administration',
self::Inhaber => 'Inhaber',
self::Buero => 'Büro',
};
}
/** @return list<string> */
public function permissions(): array
{
return match ($this) {
self::Admin => Permission::ALL,
self::Inhaber => [
Permission::OffersDelete,
Permission::CustomersDelete,
Permission::TasksDelete,
Permission::PurchasesCreate,
Permission::PurchasesDelete,
],
self::Buero => [
Permission::PurchasesComplete,
Permission::InvoicesProcess,
],
};
}
public function allows(string $permission): bool
{
return in_array($permission, $this->permissions(), true);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
final readonly class User
{
public function __construct(
public string $id,
public string $username,
public string $displayName,
public Role $role,
public bool $isActive,
public string $createdAt,
public ?string $lastLoginAt = null,
public string $passwordHash = '',
) {}
public static function fromRow(array $row): self
{
return new self(
id: (string) $row['id'],
username: (string) $row['username'],
displayName: (string) $row['display_name'],
role: Role::from((string) $row['role']),
isActive: (bool) $row['is_active'],
createdAt: (string) $row['created_at'],
lastLoginAt: isset($row['last_login_at']) ? (string) $row['last_login_at'] : null,
passwordHash: (string) ($row['password_hash'] ?? ''),
);
}
/** Die Form, die die Oberflaeche sieht. Der Hash verlaesst den Server nie. */
public function toArray(): array
{
return [
'id' => $this->id,
'username' => $this->username,
'displayName' => $this->displayName,
'role' => $this->role->value,
'roleLabel' => $this->role->label(),
'isActive' => $this->isActive,
'createdAt' => $this->createdAt,
'lastLoginAt' => $this->lastLoginAt,
'permissions' => $this->role->permissions(),
];
}
public function can(string $permission): bool
{
return $this->role->allows($permission);
}
}
+237
View File
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
use Ekdos\Auth\AuthService;
use Ekdos\Auth\SessionStore;
use Ekdos\Http\Middleware\SessionMiddleware;
use Ekdos\Support\Json;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Benutzerverwaltung.
*
* Alle Routen liegen hinter dem Recht "users.manage" (Rolle Administration).
* Vier Regeln schuetzen davor, sich selbst auszusperren:
*
* 1. Der letzte aktive Administrator kann weder geloescht noch herabgestuft
* noch deaktiviert werden.
* 2. Niemand kann das eigene Konto loeschen.
* 3. Niemand kann die eigene Rolle aendern.
* 4. Jede Rechteaenderung meldet den betroffenen Benutzer sofort ab, damit
* eine laufende Sitzung keine Rechte behaelt, die sie nicht mehr hat.
*/
final readonly class UserController
{
public function __construct(
private UserRepository $users,
private SessionStore $sessions,
) {}
public function index(Request $request, Response $response): Response
{
return Json::write($response, [
'users' => array_map(static fn (User $user): array => $user->toArray(), $this->users->all()),
'roles' => self::roleCatalogue(),
]);
}
public function create(Request $request, Response $response): Response
{
$actor = SessionMiddleware::of($request);
$body = self::body($request);
$username = trim((string) ($body['username'] ?? ''));
$displayName = trim((string) ($body['displayName'] ?? ''));
$password = (string) ($body['password'] ?? '');
$role = Role::tryFrom((string) ($body['role'] ?? ''));
if (($problem = self::validateName($username, $displayName)) !== null) {
return Json::error($response, $problem, 400);
}
if ($role === null) {
return Json::error($response, 'Bitte eine gültige Rolle auswählen.', 400);
}
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
return Json::error($response, $problem, 400);
}
if ($this->users->usernameTaken($username)) {
return Json::error($response, 'Dieser Benutzername ist bereits vergeben.', 409);
}
$user = $this->users->create($username, $displayName, $role, AuthService::hash($password));
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.create', $user->id, $user->username, ['role' => $role->value]);
return Json::write($response, ['user' => $user->toArray()], 201);
}
public function update(Request $request, Response $response, array $args): Response
{
$actor = SessionMiddleware::of($request);
$id = (string) ($args['id'] ?? '');
$user = $this->users->find($id);
if ($user === null) {
return Json::error($response, 'Dieser Benutzer existiert nicht.', 404);
}
$body = self::body($request);
$fields = [];
$changed = [];
if (array_key_exists('displayName', $body)) {
$displayName = trim((string) $body['displayName']);
if ($displayName === '' || mb_strlen($displayName) > 120) {
return Json::error($response, 'Der Anzeigename muss zwischen 1 und 120 Zeichen lang sein.', 400);
}
$fields['display_name'] = $displayName;
$changed[] = 'Anzeigename';
}
if (array_key_exists('username', $body)) {
$username = trim((string) $body['username']);
if (($problem = self::validateName($username, 'x')) !== null) {
return Json::error($response, $problem, 400);
}
if ($this->users->usernameTaken($username, $user->id)) {
return Json::error($response, 'Dieser Benutzername ist bereits vergeben.', 409);
}
$fields['username'] = $username;
$changed[] = 'Benutzername';
}
if (array_key_exists('role', $body)) {
$role = Role::tryFrom((string) $body['role']);
if ($role === null) {
return Json::error($response, 'Bitte eine gültige Rolle auswählen.', 400);
}
if ($actor?->userId === $user->id && $role !== $user->role) {
return Json::error($response, 'Die eigene Rolle kann nicht geändert werden.', 409);
}
if ($user->role === Role::Admin && $role !== Role::Admin && $this->users->countAdmins($user->id) === 0) {
return Json::error($response, 'Es muss mindestens ein aktiver Administrator bestehen bleiben.', 409);
}
$fields['role'] = $role->value;
$changed[] = 'Rolle';
}
if (array_key_exists('isActive', $body)) {
$isActive = (bool) $body['isActive'];
if (!$isActive && $user->role === Role::Admin && $this->users->countAdmins($user->id) === 0) {
return Json::error($response, 'Der letzte aktive Administrator kann nicht deaktiviert werden.', 409);
}
if (!$isActive && $actor?->userId === $user->id) {
return Json::error($response, 'Das eigene Konto kann nicht deaktiviert werden.', 409);
}
$fields['is_active'] = $isActive;
$changed[] = $isActive ? 'aktiviert' : 'deaktiviert';
}
if (array_key_exists('password', $body) && (string) $body['password'] !== '') {
$password = (string) $body['password'];
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
return Json::error($response, $problem, 400);
}
$fields['password_hash'] = AuthService::hash($password);
$changed[] = 'Passwort';
}
if ($fields === []) {
return Json::error($response, 'Es wurde nichts geändert.', 400);
}
$updated = $this->users->update($user->id, $fields);
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.update', $user->id, $user->username, ['changed' => $changed]);
// Rolle, Aktivierung oder Passwort geaendert: laufende Sitzungen beenden.
$forcesLogout = isset($fields['role']) || isset($fields['is_active']) || isset($fields['password_hash']);
$endedSessions = $forcesLogout ? $this->sessions->destroyAllFor($user->id) : 0;
return Json::write($response, [
'user' => $updated?->toArray(),
'endedSessions' => $endedSessions,
]);
}
public function delete(Request $request, Response $response, array $args): Response
{
$actor = SessionMiddleware::of($request);
$id = (string) ($args['id'] ?? '');
$user = $this->users->find($id);
if ($user === null) {
return Json::error($response, 'Dieser Benutzer existiert nicht.', 404);
}
if ($actor?->userId === $user->id) {
return Json::error($response, 'Das eigene Konto kann nicht gelöscht werden.', 409);
}
if ($user->role === Role::Admin && $this->users->countAdmins($user->id) === 0) {
return Json::error($response, 'Der letzte aktive Administrator kann nicht gelöscht werden.', 409);
}
// Erst abmelden, dann loeschen: sonst bliebe eine offene Sitzung ohne Konto zurueck.
$this->sessions->destroyAllFor($user->id);
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.delete', null, $user->username, ['role' => $user->role->value]);
$this->users->delete($user->id);
return Json::write($response, ['deleted' => true]);
}
/** Das Protokoll der Benutzerverwaltung, jüngste Einträge zuerst. */
public function audit(Request $request, Response $response): Response
{
return Json::write($response, ['entries' => $this->users->recentAudit(50)]);
}
/** @return list<array<string, mixed>> */
private static function roleCatalogue(): array
{
return array_map(static fn (Role $role): array => [
'value' => $role->value,
'label' => $role->label(),
'permissions' => array_map(Permission::label(...), $role->permissions()),
], Role::cases());
}
private static function validateName(string $username, string $displayName): ?string
{
if (preg_match('/^[a-z0-9._-]{3,40}$/i', $username) !== 1) {
return 'Der Benutzername darf 3 bis 40 Zeichen lang sein und nur Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.';
}
if ($displayName === '' || mb_strlen($displayName) > 120) {
return 'Der Anzeigename muss zwischen 1 und 120 Zeichen lang sein.';
}
return null;
}
private static function body(Request $request): array
{
$parsed = $request->getParsedBody();
return is_array($parsed) ? $parsed : Json::decode((string) $request->getBody());
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Ekdos\Users;
use PDO;
final readonly class UserRepository
{
private const string COLUMNS = 'id, username, display_name, role, is_active, created_at, updated_at, last_login_at';
public function __construct(private PDO $db) {}
/** @return list<User> */
public function all(): array
{
$rows = $this->db->query('select ' . self::COLUMNS . ' from users order by lower(display_name)')->fetchAll();
return array_map(User::fromRow(...), $rows);
}
public function find(string $id): ?User
{
$statement = $this->db->prepare('select ' . self::COLUMNS . ' from users where id = :id');
$statement->execute(['id' => $id]);
$row = $statement->fetch();
return $row === false ? null : User::fromRow($row);
}
/** Liefert den Hash mit. Wird ausschliesslich fuer die Anmeldung verwendet. */
public function findByUsernameWithHash(string $username): ?User
{
$statement = $this->db->prepare('select ' . self::COLUMNS . ', password_hash from users where lower(username) = lower(:username)');
$statement->execute(['username' => $username]);
$row = $statement->fetch();
return $row === false ? null : User::fromRow($row);
}
public function usernameTaken(string $username, ?string $exceptId = null): bool
{
$statement = $this->db->prepare('select 1 from users where lower(username) = lower(:username) and (cast(:except as uuid) is null or id <> cast(:except as uuid))');
$statement->execute(['username' => $username, 'except' => $exceptId]);
return $statement->fetchColumn() !== false;
}
/** Zaehlt aktive Administratoren, optional ohne einen bestimmten Benutzer. */
public function countAdmins(?string $exceptId = null): int
{
$statement = $this->db->prepare("select count(*) from users where role = 'admin' and is_active and (cast(:except as uuid) is null or id <> cast(:except as uuid))");
$statement->execute(['except' => $exceptId]);
return (int) $statement->fetchColumn();
}
public function create(string $username, string $displayName, Role $role, string $passwordHash, bool $isActive = true): User
{
$statement = $this->db->prepare(
'insert into users (username, display_name, role, password_hash, is_active)
values (:username, :display_name, :role, :password_hash, :is_active)
returning ' . self::COLUMNS
);
$statement->execute([
'username' => $username,
'display_name' => $displayName,
'role' => $role->value,
'password_hash' => $passwordHash,
'is_active' => $isActive ? 't' : 'f',
]);
return User::fromRow($statement->fetch());
}
/** Nur uebergebene Felder werden geschrieben, alles andere bleibt unangetastet. */
public function update(string $id, array $fields): ?User
{
$allowed = ['username', 'display_name', 'role', 'password_hash', 'is_active'];
$sets = [];
$parameters = ['id' => $id];
foreach ($fields as $column => $value) {
if (!in_array($column, $allowed, true)) {
continue;
}
$sets[] = $column . ' = :' . $column;
$parameters[$column] = is_bool($value) ? ($value ? 't' : 'f') : $value;
}
if ($sets === []) {
return $this->find($id);
}
$sets[] = 'updated_at = now()';
$statement = $this->db->prepare('update users set ' . implode(', ', $sets) . ' where id = :id returning ' . self::COLUMNS);
$statement->execute($parameters);
$row = $statement->fetch();
return $row === false ? null : User::fromRow($row);
}
public function delete(string $id): bool
{
$statement = $this->db->prepare('delete from users where id = :id');
$statement->execute(['id' => $id]);
return $statement->rowCount() > 0;
}
public function touchLogin(string $id): void
{
$statement = $this->db->prepare('update users set last_login_at = now() where id = :id');
$statement->execute(['id' => $id]);
}
public function audit(?string $actorId, string $actorName, string $action, ?string $subjectId, string $subject, array $detail = []): void
{
$statement = $this->db->prepare(
'insert into user_audit (actor_id, actor_name, action, subject_id, subject, detail)
values (cast(:actor_id as uuid), :actor_name, :action, cast(:subject_id as uuid), :subject, cast(:detail as jsonb))'
);
$statement->execute([
'actor_id' => $actorId,
'actor_name' => $actorName,
'action' => $action,
'subject_id' => $subjectId,
'subject' => $subject,
'detail' => json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
]);
}
/** @return list<array<string, mixed>> */
public function recentAudit(int $limit = 50): array
{
$statement = $this->db->prepare('select actor_name, action, subject, detail, created_at from user_audit order by created_at desc limit :limit');
$statement->bindValue('limit', $limit, PDO::PARAM_INT);
$statement->execute();
return array_map(static fn (array $row): array => [
'actor' => $row['actor_name'],
'action' => $row['action'],
'subject' => $row['subject'],
'detail' => json_decode((string) $row['detail'], true) ?: [],
'createdAt' => $row['created_at'],
], $statement->fetchAll());
}
}
+97
View File
@@ -0,0 +1,97 @@
# EK-DOS-WEB
#
# nginx macht beides: es liefert das statisch gebaute React-Bündel aus und
# reicht ausschliesslich /api/ an php-fpm weiter. Auf dem Server läuft kein
# Node und kein Anwendungsserver -- nur nginx und php8.5-fpm.
#
# Ablage: /etc/nginx/sites-available/ekdos.conf -> sites-enabled/
# Prüfen: nginx -t && systemctl reload nginx
upstream ekdos_php {
# Unix-Socket statt TCP: kein Netzwerkstack, keine offene Portnummer.
server unix:/run/php/ekdos.sock;
}
server {
listen 80;
listen [::]:80;
server_name schulung.elektro-krueger.local;
# Hinter einem TLS-Terminator (Cloudflare, vorgelagertes nginx) diesen Block
# entfernen und stattdessen den 443-Block unten verwenden.
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name schulung.elektro-krueger.local;
ssl_certificate /etc/ssl/ekdos/fullchain.pem;
ssl_certificate_key /etc/ssl/ekdos/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Das gebaute Bündel aus dem Gitea-Lauf.
root /var/www/ekdos/current;
index index.html;
# Die Anwendung ist intern; sie gehört in keinen Suchindex.
add_header X-Robots-Tag "noindex, nofollow" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "same-origin" always;
# Rechnungen und Serviceberichte sind mehrere Megabyte gross.
client_max_body_size 32m;
# ── Statische Oberfläche ──────────────────────────────────────────────
# Vite vergibt Inhaltshashes, deshalb dürfen die Bündel dauerhaft im Cache
# liegen. Nur index.html darf das nicht, sonst sieht niemand ein Update.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-store";
}
# Einseitenanwendung: jede unbekannte Adresse bekommt index.html.
location / {
try_files $uri $uri/ /index.html;
}
# ── Backend ───────────────────────────────────────────────────────────
location /api/ {
include fastcgi_params;
fastcgi_pass ekdos_php;
# Einziger Einstiegspunkt. Es gibt keine weiteren .php-Dateien im Web.
fastcgi_param SCRIPT_FILENAME /var/www/ekdos/backend/public/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param DOCUMENT_ROOT /var/www/ekdos/backend/public;
# Der Rechnungsabgleich und grosse PDFs brauchen Luft.
fastcgi_read_timeout 60s;
fastcgi_buffering off;
# Damit die Anmeldebremse die echte Adresse sieht.
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTPS on;
}
# PHP wird nirgendwo sonst ausgeführt.
location ~ \.php$ {
return 404;
}
# Punktdateien bleiben unerreichbar (.env, .git und Ähnliches).
location ~ /\. {
deny all;
}
access_log /var/log/nginx/ekdos.access.log;
error_log /var/log/nginx/ekdos.error.log warn;
}
+49
View File
@@ -0,0 +1,49 @@
; EK-DOS-WEB: eigener php-fpm-Pool.
;
; Ablage: /etc/php/8.5/fpm/pool.d/ekdos.conf
; Prüfen: php-fpm8.5 -t && systemctl restart php8.5-fpm
;
; Ein eigener Pool statt des mitgelieferten www-Pools: eigener Socket, eigener
; Benutzer, eigene Grenzen. Ein anderer Dienst auf demselben Server kann EK-DOS
; damit weder aushungern noch dessen Umgebung mitlesen.
[ekdos]
user = ekdos
group = ekdos
listen = /run/php/ekdos.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
; Zwei Personen im Büro, dazu die 20-Sekunden-Abfrage der Ticketliste.
; ondemand hält keine ungenutzten Prozesse vor.
pm = ondemand
pm.max_children = 12
pm.process_idle_timeout = 30s
pm.max_requests = 500
; Aufrufe an n8n haben ihr eigenes Zeitlimit (N8N_TIMEOUT). Dieses hier ist die
; Notbremse darüber, damit ein hängender Aufruf keinen Prozess dauerhaft bindet.
request_terminate_timeout = 60s
; Fehler gehen in den Fehlerkanal, nicht in die Antwort.
php_admin_value[display_errors] = Off
php_admin_flag[log_errors] = On
php_admin_value[error_log] = /var/log/php/ekdos-error.log
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 32M
php_admin_value[post_max_size] = 32M
; Das Backend liest und schreibt nur in seinem eigenen Verzeichnis.
php_admin_value[open_basedir] = /var/www/ekdos/backend:/tmp
; Es werden keine PHP-Sitzungen verwendet -- die Sitzung liegt in Redis.
php_admin_value[session.save_handler] = files
php_admin_flag[session.auto_start] = Off
; Opcache: der Quelltext ändert sich nur beim Ausrollen.
php_admin_flag[opcache.enable] = On
php_admin_value[opcache.validate_timestamps] = 0
php_admin_value[opcache.max_accelerated_files] = 10000
+146
View File
@@ -0,0 +1,146 @@
# EK-DOS-WEB betreiben
Zielsystem: Debian/Ubuntu mit nginx und php8.5-fpm. Postgres und Redis laufen
bereits für n8n und werden mitbenutzt (eigene Datenbank, eigene Redis-Nummer).
## 1. Pakete
```bash
sudo apt update
sudo apt install -y nginx php8.5-fpm php8.5-cli php8.5-pgsql php8.5-redis \
php8.5-mbstring php8.5-curl php8.5-intl php8.5-xml
```
`php8.5-intl` ist optional, verbessert aber die Erkennung der Kundenrücksprache
in Ticketberichten (Unicode-Normalisierung). Ohne die Erweiterung greift ein
Rückfall ohne Normalisierung.
## 2. Datenbank und Redis
```bash
sudo -u postgres createuser ekdos --pwprompt
sudo -u postgres createdb ekdos --owner=ekdos
```
Redis braucht nichts weiter -- EK-DOS legt seine Schlüssel unter dem Präfix
`ekdos:` ab und nutzt eine eigene Datenbanknummer (`REDIS_DB`).
> Redis hält Sitzungen **und** den n8n-Cache. Ist Redis weg, ist niemand mehr
> angemeldet und jede Ansicht fragt wieder direkt bei n8n an. Die Anwendung
> läuft weiter, aber langsamer und mit erneuter Anmeldung.
## 3. Verzeichnisse
```bash
sudo mkdir -p /var/www/ekdos/{releases,backend}
sudo useradd --system --home /var/www/ekdos --shell /usr/sbin/nologin ekdos
sudo chown -R ekdos:ekdos /var/www/ekdos
sudo mkdir -p /var/log/php && sudo chown ekdos:ekdos /var/log/php
```
## 4. Backend einrichten
```bash
cd /var/www/ekdos/backend
sudo -u ekdos cp .env.example .env
sudo -u ekdos editor .env
sudo -u ekdos composer install --no-dev --optimize-autoloader
sudo -u ekdos php bin/ekdos migrate
sudo -u ekdos php bin/ekdos user:create # erster Administrator
sudo -u ekdos php bin/ekdos check
```
`.env` gehört dem Benutzer `ekdos` und sollte `chmod 600` sein. Sie wird beim
Ausrollen ausdrücklich nicht überschrieben.
### n8n-Adressen
| Einstellung | Wert | warum |
| ------------------- | --------------------------------- | ----------------------------------------------------------- |
| `N8N_PUBLIC_BASE` | `https://n8n.elektro-krueger.eu` | Der Dienst auf Port 5678 ist nur über den CNAME erreichbar |
| `N8N_INTERNAL_HOST` | `10.0.11.131` | Jeder weitere n8n-Dienst auf einem anderen Port |
Alle heutigen `ek-dos-web`-Webhooks laufen über den 5678-Dienst und damit über
`N8N_PUBLIC_BASE`. Kommt später ein zweiter n8n-Dienst auf eigenem Port dazu,
wird er mit `Endpoints::internal(<port>, '<pfad>')` angesprochen und geht über
die RFC1918-Adresse -- er ist nach aussen nicht veröffentlicht.
## 5. php-fpm
```bash
sudo cp deploy/php-fpm/ekdos.pool.conf /etc/php/8.5/fpm/pool.d/ekdos.conf
sudo php-fpm8.5 -t
sudo systemctl restart php8.5-fpm
ls -l /run/php/ekdos.sock # muss www-data:www-data 0660 gehören
```
Der Pool setzt `opcache.validate_timestamps = 0`. Neuer Quelltext wird deshalb
**erst nach einem `systemctl reload php8.5-fpm`** wirksam -- die Ausrollstrecke
macht das selbst.
## 6. nginx
```bash
sudo cp deploy/nginx/ekdos.conf /etc/nginx/sites-available/ekdos.conf
sudo ln -sf /etc/nginx/sites-available/ekdos.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
Kurzfassung: `fastcgi_pass unix:/run/php/ekdos.sock` für `/api/`, alles andere
`try_files $uri $uri/ /index.html`. Kein Anwendungsserver, kein Reverse Proxy
auf einen Node-Prozess.
Läuft der Zugriff nur intern über HTTP, kann `SESSION_COOKIE_SECURE=false`
gesetzt und der 443-Block entfernt werden. Sobald TLS anliegt: wieder auf `true`.
## 7. Ausrollen
Die Gitea-Strecke (`.gitea/workflows/build.yml`) macht das bei jedem Push auf
`main`. Nötig sind:
- ein SSH-Schlüssel als Gitea-Secret `DEPLOY_SSH_KEY`
- ein Konto `deploy` auf dem Server, das nach `/var/www/ekdos` schreiben darf
- `sudo systemctl reload php8.5-fpm` ohne Passwort für dieses Konto
```
deploy ALL=(root) NOPASSWD: /bin/systemctl reload php8.5-fpm
```
Jede Veröffentlichung landet unter `releases/<sha>`; sichtbar wird sie erst
durch den Symlink-Tausch von `current`. Die letzten fünf bleiben liegen, ein
Rückschritt ist damit ein `ln -sfn`.
### Von Hand
```bash
cd frontend && pnpm install --frozen-lockfile && pnpm build
rsync -az --delete frontend/dist/ server:/var/www/ekdos/releases/manuell/
ssh server 'cd /var/www/ekdos && ln -sfn releases/manuell current && sudo systemctl reload php8.5-fpm'
```
## Prüfen, wenn etwas klemmt
```bash
php backend/bin/ekdos check # Postgres, Redis, n8n
curl -sS https://<host>/api/health # ohne Anmeldung erreichbar
tail -f /var/log/php/ekdos-error.log # Ausnahmen aus dem Backend
tail -f /var/log/nginx/ekdos.error.log
```
| Symptom | meistens |
| ------------------------------------------- | --------------------------------------------------------------------- |
| 502 auf `/api/` | Socket fehlt oder falsche Rechte; `systemctl status php8.5-fpm` |
| Anmeldung wirkt, danach sofort abgemeldet | Redis nicht erreichbar, oder `SESSION_COOKIE_SECURE=true` ohne TLS |
| Listen bleiben leer, Meldung „nicht erreichbar" | `N8N_PUBLIC_BASE` falsch, oder der Webhook ist in n8n nicht aktiv |
| Angezeigte Daten sind veraltet | Rückfall-Cache greift, weil n8n gerade nicht antwortet |
| 503 „noch nicht eingerichtet" | `N8N_INVOICE_SYNC_SECRET` bzw. `N8N_CUSTOMER_KEY` fehlt in `.env` |
| Quelltextänderung wirkt nicht | Opcache; `systemctl reload php8.5-fpm` |
## Alle Benutzer aussperren
```bash
redis-cli --scan --pattern 'ekdos:sess:*' | xargs -r redis-cli del
```
Danach muss sich jeder neu anmelden. Einzelne Konten trifft es gezielt über die
Benutzerverwaltung (Deaktivieren beendet die offenen Sitzungen sofort).
+109
View File
@@ -0,0 +1,109 @@
# n8n-Schnittstelle
Alle Fachdaten von EK-DOS-WEB liegen in n8n. Das Backend hält davon nichts vor
ausser einem kurzlebigen Cache.
## Adressen
Der n8n-Hauptdienst auf **Port 5678** ist ausschliesslich über
`https://n8n.elektro-krueger.eu` erreichbar. Jeder weitere n8n-Dienst auf einem
anderen Port ist nicht veröffentlicht und wird über **10.0.11.131** angesprochen.
In `backend/src/N8n/Endpoints.php`:
```php
$url->webhook('/offene-tickets'); // https://n8n.elektro-krueger.eu/webhook/ek-dos-web/offene-tickets
$url->internal(5679, '/webhook/irgendwas'); // http://10.0.11.131:5679/webhook/irgendwas
```
Neue Adressen gehören in diese Klasse, nicht verstreut in die Controller.
## Endpunkte
Alle unter `/webhook/ek-dos-web`. Die Spalte *Cache* nennt Gruppe sowie frische
und Rückfall-Haltbarkeit in Sekunden.
| Pfad | Verwendung | Cache |
| ----------------------------------- | -------------------------------- | ------------------------ |
| `/offene-tickets` | Ticketliste | `tickets` 15 / 600 |
| `/offene-tickets/kundenruecksprache`| Ergebnis speichern | schreibend |
| `/tickets/digitale-akte` | PDF | – |
| `/tickets/servicebericht` | PDF | – |
| `/angebote` | Angebotsliste | `offers` 30 / 600 |
| `/angebote/versendet` `/beauftragt` `/zuruecksetzen` `/loeschen` | Aktionen | schreibend |
| `/kundenstamm` | Kundenliste, Anlage, Änderung | `customers` 120 / 1800 |
| `/kundenstamm/digitale-akte` | PDF | – |
| `/rechnungen` | Rechnungen eines Kunden | `invoices` 30 / 600 |
| `/rechnungen/alle` | Gesamtübersicht (geschützt) | `invoices` 30 / 600 |
| `/rechnungen/zuordnen` `/sync` | Zuordnung, Abgleich (geschützt) | schreibend |
| `/rechnungen/pdf` | PDF | – |
| `/rechnungen-anfertigen` | Aufgabenliste | `invoices-create` 30/600 |
| `/rechnungen-anfertigen/erledigt` | Aufgabe abschließen | schreibend |
| `/rechnungen-pruefen` | Prüfliste | `invoices-review` 30/600 |
| `/rechnungen-versenden` | Versandliste und -bestätigung | `invoices-send` 30/600 |
| `/digitale-akte` | PDF als base64 im JSON | |
| `/interne-aufgaben` (+ `/erledigt` `/bearbeiten` `/loeschen`) | Aufgaben | `tasks` 20 / 600 |
| `/online-kaeufe` (+ `/erledigt` `/loeschen`) | Online-Käufe | `purchases` 30 / 600 |
| `/stundennachweise` | Monatsreport | `hours` 300 / 3600 |
## Geteilte Geheimnisse
| Kopfzeile | aus | wofür |
| -------------------------- | ------------------------- | ------------------------------------------------------------ |
| `x-ekdos-invoice-sync` | `N8N_INVOICE_SYNC_SECRET` | Rechnungsübersicht, Zuordnung, Abgleich, Kundenanlage, Angebot zurücksetzen |
| `X-EK-DOS-Customer-Key` | `N8N_CUSTOMER_KEY` | Lesen und Ändern im Kundenstamm |
| `x-ekdos-webhook-secret` | `N8N_REFRESH_SECRET` | n8n meldet EK-DOS eine Ticketänderung |
Fehlt eines davon, antworten die betroffenen Routen mit **503** und einer
Meldung, die genau das sagt -- sie schlagen nicht stumm fehl.
> Der Kundenschlüssel stand in der Next.js-Fassung als Literal im Quelltext
> (`app/api/customers/route.ts`). Er liegt jetzt in `.env`. **Er sollte in n8n
> gewechselt werden**, weil der alte Wert in der Versionsgeschichte steht.
## n8n meldet eine Ticketänderung
Der Workflow *Datenübergabe an EK-DOS-WEB* darf weiter auf seinen Endpunkt
zeigen, nur ohne Port:
```
POST https://schulung.elektro-krueger.local/api/tickets/refresh
x-ekdos-webhook-secret: <N8N_REFRESH_SECRET>
```
Anders als früher ist das kein Platzhalter mehr. In der Next.js-Fassung
antwortete die Route nur `{ok:true}` und tat sonst nichts; die Oberfläche sah
neue Tickets rein zufällig beim nächsten 20-Sekunden-Takt. Jetzt **verwirft der
Aufruf den Ticket-Cache**, sodass die nächste Abfrage die neuen Daten garantiert
sieht.
Ohne gesetztes `N8N_REFRESH_SECRET` verlangt die Route eine angemeldete Sitzung
und weist den Aufruf aus n8n mit 401 ab.
## Feldabbildung Tickets
Die einzige Stelle, an der das Backend n8n-Felder umbenennt
(`backend/src/Relay/TicketsController.php`):
| n8n | Oberfläche |
| -------------------------------- | ------------------------------ |
| `ticketnummer` / `id` | `id` |
| `kunde` / `customer` | `customer` |
| `liegenschaft` / `property` | `property` |
| `letzter_servicebericht_status` | `reportStatus` |
| `fortsetzung` | `continuation` |
| `entscheidung_code` | `decisionCode` |
| `kundenruecksprache_ergebnis` | `consultationResult` |
| `kundenruecksprache_am` / `_von` | `consultationUpdatedAt` / `By` |
`requiresCustomerConsultation` wird abgeleitet: entweder
`entscheidung_code === "TEILERLEDIGUNG_RUECKSPRACHE_KUNDE"`, oder der
Fortsetzungstext enthält „nimmt mit Kunden für das weitere Vorgehen Kontakt auf".
Zeilen ohne Ticketnummer werden verworfen.
## Bekannte Kopplung
Interne Aufgaben kennen in n8n genau zwei Empfänger: `sascha` und `svenja`. Ein
drittes EK-DOS-Konto kann sich anmelden und alle Ansichten nutzen, aber noch
keine Aufgaben zugewiesen bekommen. Dafür müsste der n8n-Workflow *Interne
Aufgaben* einen freien Empfängerschlüssel annehmen.
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="EK-DOS WEB Büroanwendung der Elektro Krüger GmbH." />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/ek-dos-favicon.svg" />
<link rel="shortcut icon" href="/ek-dos-favicon.svg" />
<title>EK-DOS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "ek-dos-web-frontend",
"displayName": "EK-DOS WEB",
"version": "3.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.16.1",
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"eslint": "9.39.4",
"typescript": "5.9.3",
"vite": "8.0.13"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="13" fill="#12344D"/>
<path d="M11 13h25l-3.6 8H20.7l-1.4 5h11.3l-3.4 8H17l-1.6 6h12.8l-3.6 9H8z" fill="#fff"/>
<path d="M39 13h13L42 31l12 20H42l-8.4-14-5.5 14H16l12.1-30H39z" fill="#93EC67"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="13" fill="#12344D"/>
<path d="M11 13h25l-3.6 8H20.7l-1.4 5h11.3l-3.4 8H17l-1.6 6h12.8l-3.6 9H8z" fill="#fff"/>
<path d="M39 13h13L42 31l12 20H42l-8.4-14-5.5 14H16l12.1-30H39z" fill="#93EC67"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 392 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 386 B

+28
View File
@@ -0,0 +1,28 @@
import { AuthProvider, useAuth } from '@/auth/AuthProvider';
import { LoadingScreen, LoginScreen } from '@/auth/LoginScreen';
import Dashboard from '@/views/Dashboard';
/**
* Der Auth-Gate.
*
* Drei Zustände, mehr gibt es nicht: es ist noch nicht entschieden, niemand ist
* angemeldet, oder jemand ist angemeldet. Erst im dritten Fall wird überhaupt
* eine Fachansicht gerendert -- so kann keine Ansicht in einen Zustand ohne
* Benutzer geraten.
*/
function Gate() {
const { user, ready } = useAuth();
if (!ready) return <LoadingScreen />;
if (!user) return <LoginScreen />;
return <Dashboard />;
}
export function App() {
return (
<AuthProvider>
<Gate />
</AuthProvider>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { config } from '@/config';
/**
* Der einzige Weg nach draussen.
*
* `credentials: 'include'` sorgt dafuer, dass das Sitzungscookie mitgeht. Es gibt
* bewusst keinen Authorization-Header und kein Token im Browser: die Sitzung
* liegt vollstaendig im Backend, der Browser haelt nur die undurchsichtige Kennung.
*/
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
/** Die Sitzung ist abgelaufen oder wurde beendet. */
get isUnauthorized(): boolean {
return this.status === 401;
}
/** Angemeldet, aber ohne das noetige Recht. */
get isForbidden(): boolean {
return this.status === 403;
}
}
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
};
/** Wird ausgeloest, sobald das Backend 401 meldet, damit die Anmeldemaske erscheint. */
type UnauthorizedListener = () => void;
const unauthorizedListeners = new Set<UnauthorizedListener>();
export function onUnauthorized(listener: UnauthorizedListener): () => void {
unauthorizedListeners.add(listener);
return () => unauthorizedListeners.delete(listener);
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, signal } = options;
const response = await fetch(`${config.api.baseUrl}${path}`, {
method,
credentials: 'include',
cache: 'no-store',
signal,
headers: body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
}).catch(() => {
throw new ApiError('Das Backend ist nicht erreichbar.', 0);
});
if (response.status === 204) return undefined as T;
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
// 401 auf der Auth-Sonde ist eine Antwort, kein Fehler: dort steht,
// dass niemand angemeldet ist. Ueberall sonst heisst es, dass die
// Sitzung gerade abgelaufen ist.
if (response.status === 401 && path !== '/api/auth/me') {
unauthorizedListeners.forEach((listener) => listener());
}
const message = typeof payload?.error === 'string' ? payload.error : `Das Backend antwortet mit HTTP ${response.status}.`;
throw new ApiError(message, response.status);
}
return payload as T;
}
export const api = {
get: <T,>(path: string, signal?: AbortSignal) => request<T>(path, { signal }),
post: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
put: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body }),
patch: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
delete: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
};
/** Adresse fuer eine PDF-Anzeige im Browser. Die Sitzung traegt ueber das Cookie. */
export function fileUrl(path: string): string {
return `${config.api.baseUrl}${path}`;
}
/**
* Übergangsbrücke für Dashboard.tsx.
*
* Die große Dashboard-Komponente ruft noch direkt `fetch("/api/…")` auf, geht
* also nicht durch `api` oben. Ohne diesen Aufsatz bliebe eine abgelaufene
* Sitzung dort unbemerkt und die Ansichten zeigten Fehlermeldungen, statt zur
* Anmeldung zurückzufallen.
*
* Diese Funktion darf ersatzlos entfallen, sobald alle Ansichten den Client
* oben verwenden.
*/
let intercepted = false;
export function watchLegacyFetchForExpiry(): void {
if (intercepted) return;
intercepted = true;
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const response = await original(input, init);
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
// Nur eigene API-Aufrufe, und die Auth-Sonde bleibt außen vor: dort ist
// 401 die reguläre Antwort für "niemand angemeldet".
if (response.status === 401 && url.includes('/api/') && !url.includes('/api/auth/me')) {
unauthorizedListeners.forEach((listener) => listener());
}
return response;
};
}
+117
View File
@@ -0,0 +1,117 @@
import { api, onUnauthorized, watchLegacyFetchForExpiry } from '@/api/client';
import { config } from '@/config';
import type { CurrentUser, PermissionName } from '@/auth/types';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
type AuthState = {
user: CurrentUser | null;
/** Solange false, ist noch nicht entschieden, ob jemand angemeldet ist. */
ready: boolean;
signIn: (username: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
can: (permission: PermissionName) => boolean;
};
const AuthContext = createContext<AuthState | null>(null);
/**
* Der Auth-Gate der Oberflaeche.
*
* Es wird nichts geraten: beim Start fragt die Anwendung /api/auth/me. 200
* bedeutet angemeldet, 401 bedeutet Anmeldemaske. Danach halten zwei Uhren die
* Sitzung im Griff -- eine verlaengert sie, solange jemand arbeitet, die andere
* meldet nach einer Stunde ohne Eingabe ab.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(null);
const [ready, setReady] = useState(false);
const lastActivity = useRef(Date.now());
const probe = useCallback(async () => {
try {
const data = await api.get<{ user: CurrentUser | null }>('/api/auth/me');
setUser(data.user);
} catch {
// 401 und "Backend nicht erreichbar" fuehren beide zur Anmeldemaske.
setUser(null);
} finally {
setReady(true);
}
}, []);
useEffect(() => {
void probe();
}, [probe]);
// Meldet das Backend irgendwo 401, faellt die Oberflaeche sofort zurueck.
// Der Aufsatz erfasst dabei auch die direkten fetch-Aufrufe in Dashboard.tsx.
useEffect(() => {
watchLegacyFetchForExpiry();
return onUnauthorized(() => setUser(null));
}, []);
// Jede Eingabe zaehlt als Aktivitaet.
useEffect(() => {
const note = () => {
lastActivity.current = Date.now();
};
const events = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
events.forEach((event) => window.addEventListener(event, note, { passive: true }));
return () => events.forEach((event) => window.removeEventListener(event, note));
}, []);
// Sitzung verlaengern, solange gearbeitet wird; sonst abmelden.
useEffect(() => {
if (!user) return;
const timer = window.setInterval(() => {
if (Date.now() - lastActivity.current > config.session.idleMs) {
void api.post('/api/auth/logout').catch(() => undefined);
setUser(null);
return;
}
void api
.post<{ user: CurrentUser | null }>('/api/auth/refresh')
.then((data) => setUser(data.user))
.catch(() => setUser(null));
}, config.session.refreshMs);
return () => window.clearInterval(timer);
}, [user]);
const signIn = useCallback(async (username: string, password: string) => {
const data = await api.post<{ user: CurrentUser }>('/api/auth/login', { username, password });
lastActivity.current = Date.now();
setUser(data.user);
}, []);
const signOut = useCallback(async () => {
await api.post('/api/auth/logout').catch(() => undefined);
setUser(null);
}, []);
const value = useMemo<AuthState>(
() => ({
user,
ready,
signIn,
signOut,
can: (permission) => user?.permissions.includes(permission) ?? false,
}),
[user, ready, signIn, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const context = useContext(AuthContext);
if (context === null) throw new Error('useAuth benötigt einen AuthProvider.');
return context;
}
+88
View File
@@ -0,0 +1,88 @@
import { useAuth } from '@/auth/AuthProvider';
import { useState, type FormEvent } from 'react';
/**
* Anmeldemaske.
*
* Die beiden fest verdrahteten Schaltflaechen "Svenja" und "Sascha" sind einem
* Benutzernamensfeld gewichen -- Konten kommen jetzt aus der eigenen Datenbank
* und werden in der Benutzerverwaltung gepflegt. Das uebrige Gestaltungsbild
* bleibt unveraendert.
*/
export function LoginScreen() {
const { signIn } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError('');
try {
await signIn(username.trim(), password);
} catch (problem) {
setError(problem instanceof Error ? problem.message : 'Die Anmeldung ist fehlgeschlagen.');
setPassword('');
} finally {
setBusy(false);
}
};
return (
<main className="login-page">
<div className="login-panel">
<div className="login-brand" aria-label="Elektro Krüger Wir setzen Sie unter Strom">
<strong>EK</strong>
<span>
<b>Elektro Krüger</b>
<small>Wir setzen Sie unter Strom</small>
</span>
</div>
<p className="login-kicker">EK-DOS</p>
<h1>Willkommen</h1>
<p className="login-copy">Bitte melde dich an, um die digitale Schaltzentrale zu öffnen.</p>
<form onSubmit={submit}>
<label>
Benutzername
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoFocus
required
/>
</label>
<label>
Passwort
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</label>
{error && <p className="login-error">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? 'Anmeldung läuft …' : 'Anmelden →'}
</button>
</form>
</div>
<div className="login-visual">
<img src="/hero-ek-dos.png" alt="" />
</div>
</main>
);
}
export function LoadingScreen() {
return (
<main className="login-page">
<div className="login-loading">EK-DOS wird geladen </div>
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
export type Role = 'admin' | 'inhaber' | 'buero';
/** Genau die Rechte, die das Backend in Permission.php fuehrt. */
export const Permission = {
UsersManage: 'users.manage',
OffersDelete: 'offers.delete',
CustomersDelete: 'customers.delete',
TasksDelete: 'tasks.delete',
PurchasesCreate: 'purchases.create',
PurchasesDelete: 'purchases.delete',
PurchasesComplete: 'purchases.complete',
InvoicesProcess: 'invoices.process',
} as const;
export type PermissionName = (typeof Permission)[keyof typeof Permission];
export type CurrentUser = {
id: string;
username: string;
/** Anzeigename, z. B. "Svenja". Die Oberflaeche zeigt ausschliesslich diesen. */
name: string;
role: Role;
roleLabel: string;
permissions: PermissionName[];
canManageUsers: boolean;
};
export type ManagedUser = {
id: string;
username: string;
displayName: string;
role: Role;
roleLabel: string;
isActive: boolean;
createdAt: string;
lastLoginAt: string | null;
permissions: PermissionName[];
};
export type RoleOption = {
value: Role;
label: string;
permissions: string[];
};
+33
View File
@@ -0,0 +1,33 @@
/**
* Baukonfiguration.
*
* Im Betrieb liefert dasselbe nginx die Oberflaeche und /api aus, deshalb ist
* die Basis leer und alle Aufrufe sind gleichursprünglich (same-origin). Damit
* traegt das Sitzungscookie ohne CORS, und es gibt keine Herkunftsliste zu pflegen.
*
* Sollte die Oberflaeche einmal auf einem eigenen Host liegen, wird hier die
* absolute Backend-Adresse eingetragen; dann braucht das Backend zusaetzlich
* eine CORS-Freigabe mit Anmeldedaten und beide Hosts muessen dieselbe Site sein.
*/
type AppConfig = {
readonly api: { readonly baseUrl: string };
readonly session: {
/** Sitzung im Hintergrund am Leben halten. */
readonly refreshMs: number;
/** Nach dieser Zeit ohne Eingabe wird abgemeldet. */
readonly idleMs: number;
};
readonly polling: {
readonly ticketsMs: number;
};
};
const shared = {
session: { refreshMs: 5 * 60 * 1000, idleMs: 60 * 60 * 1000 },
polling: { ticketsMs: 20_000 },
} as const;
const development: AppConfig = { api: { baseUrl: '' }, ...shared };
const production: AppConfig = { api: { baseUrl: '' }, ...shared };
export const config: AppConfig = import.meta.env.PROD ? production : development;
+20
View File
@@ -0,0 +1,20 @@
/** Datums- und Zeitformate, wie sie im Büro gelesen werden. */
export function formatDateTime(value?: string): string {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
}
export function formatDate(value?: string): string {
if (!value) return '';
// Mittag statt Mitternacht: so kippt ein reines Datum nicht über die Zeitzone.
const date = new Date(`${value.slice(0, 10)}T12:00:00`);
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(date);
}
+2
View File
@@ -0,0 +1,2 @@
// Bei jedem EK-DOS-WEB-Update hier die sichtbare Versionsnummer erhöhen.
export const EKDOS_VERSION = "V3.0";
+17
View File
@@ -0,0 +1,17 @@
import { App } from '@/App';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@/styles/globals.css';
import '@/styles/dashboard-fixes.css';
import '@/styles/users.css';
const container = document.getElementById('root');
if (container === null) throw new Error('Das Wurzelelement #root fehlt in index.html.');
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
+21
View File
@@ -0,0 +1,21 @@
/* Dashboard-Korrekturen nach Designabnahme */
.app-nav button:nth-of-type(even)>span:first-child{background:#173a57;color:#62b7ff}
.helga-card{grid-column:3;grid-row:1}
.dashboard-inbox{grid-column:2}
.helga-photo{display:block;overflow:hidden;background:#102e49}
.helga-photo img{display:block;width:100%;height:100%;object-fit:cover;object-position:center 24%}
.helga-card{grid-template-columns:309px minmax(0,1fr);min-height:369px}.helga-photo{width:309px;height:369px}
.app-nav button .nav-chevron{margin-left:auto}
.app-nav button .nav-chevron+.nav-badge{margin-left:8px}
.dashboard-offers{display:grid;grid-column:3;grid-row:2;align-content:start}.dashboard-offers>button{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;border:0;border-top:1px solid #233748;padding:15px 0;background:transparent;color:inherit;text-align:left;cursor:pointer}.dashboard-offers b{display:block;font-size:13px}.dashboard-offers small{display:block;margin-top:3px;color:#849aaa;font-size:11px}.dashboard-offers em{color:#ffa85b;font-size:10px;font-style:normal;font-weight:800}.dashboard-offers>button:hover b{color:#65bbff}
.customer-row-actions{display:flex;justify-content:flex-end;gap:8px;white-space:nowrap}
.customers-table .customer-row-actions button{border:1px solid #4a7690;border-radius:7px;padding:7px 9px;background:#183645;color:#d8edf5;font-size:10px;font-weight:900;cursor:pointer}
.customers-table .customer-row-actions button:first-child{border-color:#79c96c;background:#19412d;color:#d9ffc7}
.customers-table .customer-row-actions button:hover{background:#245168;color:#fff}.customers-table .customer-row-actions button:first-child:hover{background:#256b36}
.invoice-modal{width:min(760px,100%)}.invoice-modal-hint{margin:-10px 0 0;color:#a9c1ce;font-size:13px}.invoice-property-list{display:grid;gap:10px}.invoice-property-card{overflow:hidden;border:1px solid #3c6172;border-radius:10px;background:#102633}.invoice-property-card summary{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;padding:15px 16px;cursor:pointer;list-style:none}.invoice-property-card summary::-webkit-details-marker{display:none}.invoice-property-card summary:hover{background:#173442}.invoice-property-card summary span:nth-child(2){display:grid;gap:3px}.invoice-property-card summary b{color:#f0f8fb;font-size:16px}.invoice-property-card summary small{color:#9be97c;font-size:11px;font-weight:800;text-transform:uppercase}.invoice-property-icon{display:grid;place-items:center;width:33px;height:33px;border:1px solid #75d862;border-radius:8px;background:#19412d;color:#a9f78a;font-size:19px}.invoice-property-card summary i{color:#9be97c;font-size:19px;font-style:normal}.invoice-property-card[open] summary{border-bottom:1px solid #3c6172}.invoice-items{display:grid;gap:8px;padding:12px;background:#0c1d27}.invoice-item{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px;border:1px solid #314e5d;border-radius:8px;background:#0d202a}.invoice-item span{display:grid;gap:3px}.invoice-item b{color:#e9f4f8;font-size:13px}.invoice-item small{color:#a9c1ce;font-size:11px}.invoice-item a,.invoice-modal-actions button{display:inline-flex;justify-content:center;align-items:center;border:1px solid #9be97c;border-radius:8px;padding:9px 12px;background:#184b2a;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;cursor:pointer}.invoice-item a:hover{background:#256b36;color:#fff}.invoice-modal-actions{display:flex;justify-content:flex-end;padding-top:2px}.invoice-modal-actions button{border-color:#608091;background:transparent;color:#dcecf3}@media(max-width:700px){.invoice-item{align-items:stretch;flex-direction:column}.invoice-item a{width:100%}}
@media(max-width:1270px){.helga-card{grid-column:2;grid-row:auto;grid-template-columns:180px minmax(0,1fr);min-height:216px}.helga-photo{width:180px;height:216px}.dashboard-offers{grid-column:1;grid-row:auto}}
@media(max-width:880px){.helga-card,.dashboard-inbox,.dashboard-offers{grid-column:auto;grid-row:auto}}
.activity-list .activity-status.orange{color:#ffa254}
.activity-list .activity-status.green{color:#62d589;text-shadow:0 0 8px #62d58980}
.activity-list .activity-status.blue{color:#4db5ff}
.invoices-overview-page{display:grid;gap:18px;padding:30px;min-width:0}.invoices-overview-page>.offers-heading>button{border:1px solid #4a7690;border-radius:8px;padding:10px 14px;background:#183645;color:#d8edf5;font-weight:900;cursor:pointer}.invoices-overview-page>.offers-heading>button:hover{background:#245168;color:#fff}.invoice-overview-tabs{display:flex;gap:9px;border-bottom:1px solid #294353;padding-bottom:12px}.invoice-overview-tabs button{border:1px solid #426778;border-radius:8px;padding:9px 14px;background:#102633;color:#abc2ce;font-size:12px;font-weight:900;cursor:pointer}.invoice-overview-tabs button.active,.invoice-overview-tabs button:hover{border-color:#67b9ff;background:#173a57;color:#e8f6ff}.invoice-overview-list{display:grid;gap:9px}.invoice-overview-item{display:grid;grid-template-columns:.8fr 1.4fr 1.4fr .8fr auto;align-items:center;gap:16px;padding:16px;border:1px solid #315667;border-radius:10px;background:#112b39}.invoice-overview-item>div{display:grid;gap:4px;min-width:0}.invoice-overview-item small{color:#8fb5c7;font-size:10px;font-weight:900;letter-spacing:.08em}.invoice-overview-item b{overflow:hidden;color:#edf8fb;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item span{overflow:hidden;color:#b8cbd4;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item a{display:inline-flex;align-items:center;justify-content:center;border:1px solid #8fd979;border-radius:8px;padding:9px 12px;background:#19412d;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;white-space:nowrap}.invoice-overview-item a:hover{background:#256b36;color:#fff}@media(max-width:900px){.invoices-overview-page{padding:20px}.invoice-overview-item{grid-template-columns:1fr 1fr}.invoice-overview-item a{grid-column:1/-1;width:100%}}@media(max-width:520px){.invoice-overview-item{grid-template-columns:1fr}.invoice-overview-tabs{flex-direction:column}.invoice-overview-tabs button{width:100%}}
File diff suppressed because one or more lines are too long
+286
View File
@@ -0,0 +1,286 @@
/* Benutzerverwaltung. Setzt auf den vorhandenen Listenstil der Ticketansicht auf
und ergaenzt nur, was es dort noch nicht gibt: Formular, Rollenchips, Protokoll. */
.users-notice {
max-width: 720px;
margin: 0 0 18px;
padding: 14px 18px;
border: 1px solid #9fd6a4;
border-radius: 10px;
background: #f1fbef;
color: #216c2c;
font-size: 13px;
}
.users-form {
margin: 0 0 22px;
padding: 22px 24px;
border: 1px solid #c7dce8;
border-radius: 12px;
background: #fff;
box-shadow: 0 10px 24px #294f670e;
}
.users-form h3 {
margin: 0 0 16px;
font-size: 18px;
letter-spacing: -0.03em;
}
.users-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px 18px;
}
.users-form label {
display: grid;
gap: 6px;
color: #3d596d;
font-size: 12px;
font-weight: 800;
}
.users-form input,
.users-form select {
width: 100%;
border: 1px solid #b6cfdd;
border-radius: 8px;
padding: 11px 12px;
background: #fbfdff;
color: #17384e;
font: inherit;
outline: none;
}
.users-form input:focus,
.users-form select:focus {
border-color: #5fb85f;
box-shadow: 0 0 0 3px #9be97c33;
}
.users-form input:disabled,
.users-form select:disabled {
background: #eef3f6;
color: #6b8091;
cursor: not-allowed;
}
.users-form label small {
color: #6a8290;
font-size: 11px;
font-weight: 400;
}
.users-form > button,
.users-form-actions button {
margin-top: 18px;
border: 0;
border-radius: 8px;
padding: 12px 18px;
background: #2f8744;
color: #fff;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.users-form > button:disabled,
.users-form-actions button:disabled {
opacity: 0.62;
cursor: wait;
}
.users-form-actions {
display: flex;
gap: 10px;
}
.users-form-actions .users-secondary {
background: #fff;
border: 1px solid #9fbedb;
color: #1b3047;
}
.users-permissions {
margin-top: 16px;
padding: 12px 14px;
border-radius: 8px;
background: #f2f9fd;
color: #3d596d;
font-size: 12px;
}
.users-permissions b {
display: block;
margin-bottom: 6px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.users-permissions ul {
margin: 0;
padding-left: 18px;
line-height: 1.6;
}
/* Rollen als farbige Chips, damit die Tabelle auf einen Blick lesbar ist. */
.users-role-admin {
background: #ffe9c9;
color: #8a5411;
}
.users-role-inhaber {
background: #dbe7ff;
color: #24478f;
}
.users-role-buero {
background: #e6f7dd;
color: #25753a;
}
.users-state-active {
color: #25753a;
font-weight: 800;
}
.users-state-inactive {
color: #a82028;
font-weight: 800;
}
.users-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.users-actions button {
border: 1px solid #9fbedb;
border-radius: 6px;
padding: 7px 10px;
background: #fff;
color: #1b3047;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.users-actions button:hover:not(:disabled) {
border-color: #6cc55a;
background: #f1fbf3;
}
.users-actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.users-actions .users-danger {
border-color: #e0a9a4;
color: #a1332d;
}
.users-actions .users-danger:hover:not(:disabled) {
border-color: #d66b6b;
background: #fff4f3;
}
.users-audit {
margin-top: 26px;
padding: 20px 22px;
border: 1px solid #c7dce8;
border-radius: 12px;
background: #fff;
}
.users-audit h3 {
margin: 0 0 12px;
font-size: 15px;
}
.users-audit ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.users-audit li {
display: flex;
gap: 14px;
align-items: baseline;
padding-bottom: 8px;
border-bottom: 1px solid #e6eef3;
font-size: 12px;
}
.users-audit li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.users-audit b {
flex: 0 0 150px;
color: #3d596d;
font-size: 11px;
}
.users-audit span {
color: #244258;
}
@media (max-width: 800px) {
.users-audit li {
flex-direction: column;
gap: 2px;
}
.users-audit b {
flex: none;
}
}
/* Dunkles Farbschema, passend zum Rest der Anwendung. */
:root[data-theme='dark'] .users-form,
:root[data-theme='dark'] .users-audit {
border-color: #34505f;
background: #172a36;
color: #e5eff5;
}
:root[data-theme='dark'] .users-form input,
:root[data-theme='dark'] .users-form select {
border-color: #3a5868;
background: #132630;
color: #e3eff5;
}
:root[data-theme='dark'] .users-form label,
:root[data-theme='dark'] .users-permissions,
:root[data-theme='dark'] .users-audit span {
color: #b4c5cf;
}
:root[data-theme='dark'] .users-permissions {
background: #1d3544;
}
:root[data-theme='dark'] .users-notice {
border-color: #39704a;
background: #163023;
color: #d7f3d9;
}
:root[data-theme='dark'] .users-actions button {
border-color: #3a5868;
background: #132630;
color: #dceaf2;
}
:root[data-theme='dark'] .users-audit li {
border-color: #294553;
}
File diff suppressed because it is too large Load Diff
+408
View File
@@ -0,0 +1,408 @@
import { api } from '@/api/client';
import { useAuth } from '@/auth/AuthProvider';
import type { ManagedUser, Role, RoleOption } from '@/auth/types';
import { formatDateTime } from '@/lib/format';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
type UsersPayload = { users: ManagedUser[]; roles: RoleOption[] };
type AuditEntry = { actor: string; action: string; subject: string; detail: Record<string, unknown>; createdAt: string };
const emptyDraft = { username: '', displayName: '', role: 'buero' as Role, password: '' };
/**
* Benutzerverwaltung.
*
* Nur fuer die Rolle Administration sichtbar. Das Backend setzt dieselben Regeln
* noch einmal durch -- diese Ansicht macht sie nur sichtbar, sie ersetzt sie nicht.
*/
export function UsersView() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<ManagedUser[]>([]);
const [roles, setRoles] = useState<RoleOption[]>([]);
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [busyId, setBusyId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState(emptyDraft);
const [editing, setEditing] = useState<ManagedUser | null>(null);
const [editDraft, setEditDraft] = useState({ username: '', displayName: '', role: 'buero' as Role, password: '' });
const [deleteConfirming, setDeleteConfirming] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await api.get<UsersPayload>('/api/users');
setUsers(data.users);
setRoles(data.roles);
} catch (problem) {
setError(problem instanceof Error ? problem.message : 'Die Benutzer konnten nicht geladen werden.');
} finally {
setLoading(false);
}
}, []);
const refreshAudit = useCallback(async () => {
try {
const data = await api.get<{ entries: AuditEntry[] }>('/api/users/audit');
setAudit(data.entries);
} catch {
// Das Protokoll ist eine Beigabe. Faellt es aus, bleibt die Verwaltung nutzbar.
}
}, []);
useEffect(() => {
void refresh();
void refreshAudit();
}, [refresh, refreshAudit]);
const run = async (id: string | null, action: () => Promise<string>) => {
setBusyId(id ?? 'form');
setError('');
setNotice('');
try {
setNotice(await action());
await refresh();
await refreshAudit();
} catch (problem) {
setError(problem instanceof Error ? problem.message : 'Die Aktion ist fehlgeschlagen.');
} finally {
setBusyId(null);
}
};
const create = (event: FormEvent) => {
event.preventDefault();
void run(null, async () => {
await api.post('/api/users', draft);
setDraft(emptyDraft);
setCreating(false);
return `Benutzer „${draft.displayName}“ wurde angelegt.`;
});
};
const saveEdit = (event: FormEvent) => {
event.preventDefault();
if (!editing) return;
void run(editing.id, async () => {
const changes: Record<string, unknown> = {};
if (editDraft.displayName !== editing.displayName) changes.displayName = editDraft.displayName;
if (editDraft.username !== editing.username) changes.username = editDraft.username;
if (editDraft.role !== editing.role) changes.role = editDraft.role;
if (editDraft.password !== '') changes.password = editDraft.password;
if (Object.keys(changes).length === 0) {
setEditing(null);
return 'Es wurde nichts geändert.';
}
const result = await api.patch<{ endedSessions: number }>(`/api/users/${editing.id}`, changes);
setEditing(null);
return result.endedSessions > 0
? `Gespeichert. ${result.endedSessions} offene Sitzung(en) wurden beendet.`
: 'Gespeichert.';
});
};
const toggleActive = (user: ManagedUser) =>
void run(user.id, async () => {
await api.patch(`/api/users/${user.id}`, { isActive: !user.isActive });
return user.isActive ? `${user.displayName}“ ist jetzt deaktiviert.` : `${user.displayName}“ ist wieder aktiv.`;
});
const remove = (user: ManagedUser) =>
void run(user.id, async () => {
await api.delete(`/api/users/${user.id}`);
setDeleteConfirming(null);
return `Benutzer „${user.displayName}“ wurde gelöscht.`;
});
const startEdit = (user: ManagedUser) => {
setEditing(user);
setEditDraft({ username: user.username, displayName: user.displayName, role: user.role, password: '' });
setCreating(false);
setNotice('');
setError('');
};
const activeAdmins = users.filter((user) => user.role === 'admin' && user.isActive).length;
return (
<section className="tickets-page users-page">
<div className="tickets-heading">
<div>
<p className="home-kicker">Verwaltung</p>
<h2>Benutzer</h2>
<p>
Konten, Rollen und Rechte für EK-DOS. Jede Änderung an Rolle, Passwort oder Status beendet die offenen
Sitzungen des betroffenen Kontos sofort.
</p>
</div>
<div className="tickets-actions">
<span>{users.length} Konten · {activeAdmins} aktive Administration</span>
<button
onClick={() => {
setCreating((open) => !open);
setEditing(null);
}}
>
{creating ? 'Abbrechen' : 'Benutzer anlegen'}
</button>
</div>
</div>
{error && <p className="tickets-error">{error}</p>}
{notice && <p className="users-notice">{notice}</p>}
{creating && (
<form className="users-form" onSubmit={create}>
<h3>Neuer Benutzer</h3>
<div className="users-form-grid">
<label>
Benutzername
<input
value={draft.username}
onChange={(event) => setDraft({ ...draft, username: event.target.value })}
placeholder="z. B. svenja"
autoComplete="off"
required
/>
</label>
<label>
Anzeigename
<input
value={draft.displayName}
onChange={(event) => setDraft({ ...draft, displayName: event.target.value })}
placeholder="z. B. Svenja"
required
/>
</label>
<label>
Rolle
<select value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value as Role })}>
{roles.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</label>
<label>
Passwort
<input
type="password"
value={draft.password}
onChange={(event) => setDraft({ ...draft, password: event.target.value })}
autoComplete="new-password"
minLength={12}
required
/>
<small>Mindestens 12 Zeichen.</small>
</label>
</div>
<RolePermissions roles={roles} selected={draft.role} />
<button type="submit" disabled={busyId === 'form'}>
{busyId === 'form' ? 'Wird angelegt …' : 'Benutzer anlegen'}
</button>
</form>
)}
{editing && (
<form className="users-form" onSubmit={saveEdit}>
<h3>{editing.displayName} bearbeiten</h3>
<div className="users-form-grid">
<label>
Benutzername
<input
value={editDraft.username}
onChange={(event) => setEditDraft({ ...editDraft, username: event.target.value })}
autoComplete="off"
required
/>
</label>
<label>
Anzeigename
<input
value={editDraft.displayName}
onChange={(event) => setEditDraft({ ...editDraft, displayName: event.target.value })}
required
/>
</label>
<label>
Rolle
<select
value={editDraft.role}
onChange={(event) => setEditDraft({ ...editDraft, role: event.target.value as Role })}
disabled={editing.id === currentUser?.id}
>
{roles.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
{editing.id === currentUser?.id && <small>Die eigene Rolle kann nicht geändert werden.</small>}
</label>
<label>
Neues Passwort
<input
type="password"
value={editDraft.password}
onChange={(event) => setEditDraft({ ...editDraft, password: event.target.value })}
autoComplete="new-password"
placeholder="leer lassen, um es beizubehalten"
/>
</label>
</div>
<RolePermissions roles={roles} selected={editDraft.role} />
<div className="users-form-actions">
<button type="submit" disabled={busyId === editing.id}>
{busyId === editing.id ? 'Wird gespeichert …' : 'Speichern'}
</button>
<button type="button" className="users-secondary" onClick={() => setEditing(null)}>
Abbrechen
</button>
</div>
</form>
)}
<div className="tickets-table-wrap">
<table className="tickets-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzte Anmeldung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{loading && users.length === 0 && (
<tr>
<td colSpan={5}>Benutzer werden geladen </td>
</tr>
)}
{!loading && users.length === 0 && (
<tr>
<td colSpan={5}>Es ist noch kein Benutzer angelegt.</td>
</tr>
)}
{users.map((user) => (
<tr key={user.id}>
<td>
<b>{user.displayName}</b>
<small>{user.username}{user.id === currentUser?.id ? ' · das bist du' : ''}</small>
</td>
<td>
<span className={`ticket-status users-role-${user.role}`}>{user.roleLabel}</span>
</td>
<td>
<span className={user.isActive ? 'users-state-active' : 'users-state-inactive'}>
{user.isActive ? 'aktiv' : 'deaktiviert'}
</span>
</td>
<td>{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : 'noch nie'}</td>
<td className="users-actions">
<button onClick={() => startEdit(user)} disabled={busyId !== null}>
Bearbeiten
</button>
<button
onClick={() => toggleActive(user)}
disabled={busyId !== null || user.id === currentUser?.id}
title={user.id === currentUser?.id ? 'Das eigene Konto kann nicht deaktiviert werden.' : ''}
>
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
{deleteConfirming === user.id ? (
<>
<button className="users-danger" onClick={() => remove(user)} disabled={busyId !== null}>
Wirklich löschen
</button>
<button onClick={() => setDeleteConfirming(null)}>Abbrechen</button>
</>
) : (
<button
className="users-danger"
onClick={() => setDeleteConfirming(user.id)}
disabled={busyId !== null || user.id === currentUser?.id}
title={user.id === currentUser?.id ? 'Das eigene Konto kann nicht gelöscht werden.' : ''}
>
Löschen
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{audit.length > 0 && (
<div className="users-audit">
<h3>Letzte Änderungen</h3>
<ul>
{audit.map((entry, index) => (
<li key={`${entry.createdAt}-${index}`}>
<b>{formatDateTime(entry.createdAt)}</b>
<span>
{entry.actor} · {describeAction(entry.action)} · {entry.subject}
</span>
</li>
))}
</ul>
</div>
)}
</section>
);
}
function RolePermissions({ roles, selected }: { roles: RoleOption[]; selected: Role }) {
const role = roles.find((candidate) => candidate.value === selected);
if (!role) return null;
return (
<div className="users-permissions">
<b>Diese Rolle darf zusätzlich:</b>
{role.permissions.length === 0 ? (
<span>nur die allgemeinen Büroansichten</span>
) : (
<ul>
{role.permissions.map((permission) => (
<li key={permission}>{permission}</li>
))}
</ul>
)}
</div>
);
}
function describeAction(action: string): string {
switch (action) {
case 'user.create':
return 'angelegt';
case 'user.update':
return 'geändert';
case 'user.delete':
return 'gelöscht';
case 'user.password':
return 'Passwort zurückgesetzt';
case 'password.self':
return 'eigenes Passwort geändert';
default:
return action;
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+48
View File
@@ -0,0 +1,48 @@
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, type UserConfig } from 'vite';
/**
* Funktionsform, damit ein Produktionsbau (Gitea-Runner) nichts vom
* Entwicklungsserver anfasst. Der Bau erzeugt ausschliesslich statische
* Dateien; auf dem Zielsystem laeuft kein Node.
*/
export default defineConfig(({ command }) => {
const config: UserConfig = {
plugins: [react()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
build: {
outDir: 'dist',
// Quellkarten bleiben aus: das Buendel liegt auf einem erreichbaren
// nginx und soll den Quelltext nicht mitliefern.
sourcemap: false,
target: 'es2022',
rollupOptions: {
output: {
// React getrennt halten, damit ein Oberflaechen-Update nicht
// den gesamten Cache der Anwender entwertet.
manualChunks: { react: ['react', 'react-dom'] },
},
},
},
};
// Ab hier nur noch Entwicklungsserver. Ein `vite build` erreicht diese
// Zeilen nie und braucht deshalb weder Proxy noch laufendes Backend.
if (command !== 'serve') return config;
config.server = {
port: 5173,
strictPort: true,
proxy: {
// Im Betrieb macht das nginx. Lokal zeigt der Proxy auf
// `php -S 127.0.0.1:8080 -t backend/public`.
'/api': {
target: process.env.EKDOS_BACKEND ?? 'http://127.0.0.1:8080',
changeOrigin: false,
},
},
};
return config;
});