Compare commits

..
14 Commits
Author SHA1 Message Date
florian.wilde 6cc39591b7 Merge branch 'phase/012-benachrichtigungen' 2026-09-17 22:33:17 +02:00
florian.wildeandClaude Opus 5 9e770a99b1 feat(alerts): Benachrichtigungen per E-Mail und Webhook (#012)
SMTP mit STARTTLS fuer Microsoft 365, Webhook mit HMAC-Signatur, taegliche oder
woechentliche Zusammenfassung, Versandprotokoll und Testversand. Keine
Doppelmeldungen dank Pruefung gegen das Protokoll. 134 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:33:17 +02:00
florian.wilde 179f751b1c Merge branch 'phase/011-warnregeln' 2026-09-17 22:25:33 +02:00
florian.wildeandClaude Opus 5 8a299f6c5e feat(alerts): zehn Warnregeln mit Worker, Liste und Einstellungen (#011)
Regeln als reine Funktionen, Abgleich oeffnet und schliesst Warnungen anhand
eines Fingerabdrucks. Worker wertet stuendlich aus, zusaetzlich nach jedem
Report. 123 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:25:33 +02:00
florian.wilde 5018589f08 Merge branch 'phase/010-export' 2026-09-17 22:12:58 +02:00
florian.wildeandClaude Opus 5 cb009efd7d feat(export): CSV- und PDF-Ausgaben fuer Hosts, Kunden und Rechnungen (#010)
PDFs entstehen serverseitig mit @react-pdf/renderer, mit einfarbigem Logo,
wiederholtem Spaltenkopf und Summenzeile. Alle Exporte uebernehmen die Filter
der jeweiligen Ansicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:12:58 +02:00
florian.wilde 6168ad69f2 Merge branch 'phase/009-jahresbahn' 2026-09-17 21:57:09 +02:00
florian.wildeandClaude Opus 5 1a5327dced feat(jahresbahn): Zeitleiste und Ablauf-Kalender aus der Datenbank (#009)
Zwoelf-Monats-Bahn mit Heute-Linie und Gruppierung nach Kunde, 90-Tage-Kalender
mit Rechnungsstatus sowie eine Karte fuer bereits abgelaufene Subscriptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:57:09 +02:00
florian.wilde 81b2066d61 Merge branch 'phase/008-laufzeiten-rechnungen' 2026-09-17 21:54:45 +02:00
florian.wildeandClaude Opus 5 558dfea34c feat(rechnungen): Laufzeiten bestaetigen, Rechnungen erfassen und Status setzen (#008)
Rechnungen mit Positionen: gleiche Nummer ergibt eine Sammelrechnung ueber mehrere
Laufzeiten. PDF-Upload mit Inhaltspruefung und Ablage unter der Pruefsumme,
Haekchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr. 78 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:54:45 +02:00
florian.wilde 1e1d6c4418 Merge branch 'phase/020b-gitea-token' 2026-09-17 21:44:01 +02:00
florian.wildeandClaude Opus 5 2dbb30041b feat(tooling): Gitea-Token aus den Git-Zugangsdaten lesen (#020)
Serveradresse und Repository stammen aus dem Remote, der Token aus dem
Schluesselbund bzw. ~/.git-credentials. Ein zweiter Token in der .env entfaellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:44:01 +02:00
florian.wilde 51243c29fa Merge branch 'phase/020-gitea-abgleich' 2026-09-17 21:17:23 +02:00
florian.wildeandClaude Opus 5 2df43e1517 feat(tooling): Issue-Dateien mit dem Gitea-Tracker abgleichen (#020)
Skript liest docs/issues/, legt Labels und Meilensteine an, erkennt vorhandene
Issues an der Nummer im Titel und schliesst erledigte. Trockenlauf ohne Token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:17:23 +02:00
65 changed files with 6754 additions and 218 deletions
+14
View File
@@ -12,5 +12,19 @@ ADMIN_PASSWORD="bitte-aendern"
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails) # Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails)
APP_URL="http://localhost:3000" APP_URL="http://localhost:3000"
# Zeitplan des Workers (Standard: stündlich zur vollen Stunde)
WORKER_CRON="0 * * * *"
# Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr)
ZUSAMMENFASSUNG_CRON="0 7 * * *"
# Ablage für hochgeladene Rechnungs-PDFs
UPLOAD_DIR="./data/uploads"
# Demo-Daten beim Seed anlegen (1 = ja) # Demo-Daten beim Seed anlegen (1 = ja)
SEED_DEMO="0" SEED_DEMO="0"
# Abgleich der Issue-Dateien mit dem Gitea-Tracker (npm run issues:sync)
GITEA_URL="https://git.example.de"
GITEA_REPO="benutzer/ProxLedger"
GITEA_TOKEN=""
+56
View File
@@ -17,6 +17,62 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.8.0] – 2026-09-17
### Hinzugefügt
- E-Mail-Versand über SMTP mit STARTTLS, abgestimmt auf Microsoft 365 (#012)
- Generischer Webhook mit JSON-Nutzdaten und optionaler HMAC-Signatur (#012)
- Tägliche oder wöchentliche Zusammenfassung mit offenen Warnungen, Rechnungspunkten und bald ablaufenden Subscriptions (#012)
- Einstellungsseite mit Testversand und Versandprotokoll (#012)
### Sicherheit
- Benachrichtigungen werden je Warnung nur einmal verschickt; fehlgeschlagene Versuche werden protokolliert und später wiederholt (#012)
## [0.7.0] – 2026-09-17
### Hinzugefügt
- Zehn Warnregeln: Ablauf in 30/14/7 Tagen, Status nicht aktiv, Socket-Abweichung, keine Meldung seit 48 Stunden, geänderte Server-ID, mehrfach gemeldeter Key, Host ohne Kunde, fällige Proxmox-Rechnung, fehlende und überfällige Kundenrechnung (#011)
- Worker-Prozess (`npm run worker`), der die Regeln stündlich auswertet; zusätzlich Auswertung nach jedem eingehenden Report (#011)
- Seite „Warnungen“ mit Filter offen/erledigt, Quittieren, Schließen und sofortiger Prüfung (#011)
- Einstellungsseite für Regeln und Schwellwerte (#011)
### Geändert
- Warnungen schließen sich automatisch, sobald die Ursache behoben ist; Titel und Beschreibung werden laufend nachgeführt (#011)
## [0.6.0] – 2026-09-17
### Hinzugefügt
- Jahresbahn mit zwölf Monaten, Heute-Linie und Gruppierung nach Kunde, aus echten Daten (#009)
- Ablauf-Kalender für die nächsten 90 Tage samt Rechnungsstatus und eine Übersicht bereits abgelaufener Subscriptions (#009)
- CSV-Export für Kundenübersicht und Rechnungsliste, PDF-Export für Hosts, Kunden und Rechnungen (#010)
- Kundenunterlage als PDF: Hosts, Laufzeiten, Summen pro Jahr und Marge eines Kunden (#010)
### Geändert
- Die Oberfläche arbeitet vollständig mit der Datenbank; Beispieldaten dienen nur noch dem Seed (#009)
## [0.5.0] – 2026-09-17
### Hinzugefügt
- Laufzeit-Vorschläge der Agents bestätigen oder verwerfen (#008)
- Proxmox- und Kundenrechnungen erfassen; gleiche Rechnungsnummer fasst mehrere Laufzeiten zu einer Sammelrechnung zusammen (#008)
- PDF-Upload für beide Rechnungsarten mit Prüfung des Dateiinhalts und Ablage unter der Prüfsumme (#008)
- Rechnungsübersicht mit Häkchen „bezahlt“ und „erstellt“ direkt in der Zeile, Filter nach Status, Kunde, Jahr und Suchbegriff (#008)
- Detailseite je Laufzeit mit Beträgen, Marge und Übersicht der Sammelrechnung (#008)
- Laufzeiten lassen sich von Hand anlegen (#008)
### Geändert
- Die Aufgabenliste auf dem Dashboard setzt beim Abhaken den Rechnungsstatus in der Datenbank (#008)
## [0.4.2] – 2026-09-17
### Geändert
- Der Issue-Abgleich holt Serveradresse und Repository aus dem Git-Remote und den Token aus den hinterlegten Git-Zugangsdaten; ein zweiter Token in der `.env` ist nicht mehr nötig (#020)
## [0.4.1] – 2026-09-17
### Hinzugefügt
- Abgleich der Issue-Dateien mit dem Gitea-Tracker über `npm run issues:sync`, inklusive Labels, Meilensteinen und Trockenlauf (#020)
## [0.4.0] – 2026-09-17 ## [0.4.0] – 2026-09-17
### Hinzugefügt ### Hinzugefügt
+31 -3
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 2 h 38 min **Gesamt:** 4 h 53 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -13,6 +13,12 @@
| #005 Agent-Skript | 0 h 25 min | | #005 Agent-Skript | 0 h 25 min |
| #006 Kunden und Hostzuweisung | 0 h 35 min | | #006 Kunden und Hostzuweisung | 0 h 35 min |
| #007 Host-Tabelle und Detailansichten | 0 h 25 min | | #007 Host-Tabelle und Detailansichten | 0 h 25 min |
| #020 Issues nach Gitea übertragen | 0 h 20 min |
| #008 Laufzeiten und Rechnungen | 0 h 35 min |
| #009 Jahresbahn und Ablauf-Kalender | 0 h 15 min |
| #010 Export CSV und PDF | 0 h 20 min |
| #011 Warnregeln | 0 h 30 min |
| #012 Benachrichtigungen | 0 h 25 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -20,12 +26,18 @@
| v0.2.0 | 0 h 25 min | **abgeschlossen** | v0.2.0 | 0 h 25 min | **abgeschlossen**
| v0.3.0 | 0 h 35 min | **abgeschlossen** | v0.3.0 | 0 h 35 min | **abgeschlossen**
| v0.4.0 | 0 h 25 min | **abgeschlossen** | v0.4.0 | 0 h 25 min | **abgeschlossen**
| v0.4.1 | 0 h 10 min | **abgeschlossen**
| v0.4.2 | 0 h 10 min | **abgeschlossen**
| v0.5.0 | 0 h 35 min | **abgeschlossen**
| v0.6.0 | 0 h 35 min | **abgeschlossen**
| v0.7.0 | 0 h 30 min | **abgeschlossen**
| v0.8.0 | 0 h 25 min | **abgeschlossen**
--- ---
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.4.0 **Version:** 0.8.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -46,8 +58,24 @@
- Issues #017 bis #019 aus bisherigen Notizen angelegt (Favicons, Feldtest des Agents, CI-Pipeline) - Issues #017 bis #019 aus bisherigen Notizen angelegt (Favicons, Feldtest des Agents, CI-Pipeline)
- #007 abgeschlossen: Host-Tabelle mit Suche, fünf Filtern, Sortierung, Seitenblättern und CSV-Export; Host-Detail mit sechs Tabs, Notizen, Tags, Kundenzuweisung und Token-Widerruf - #007 abgeschlossen: Host-Tabelle mit Suche, fünf Filtern, Sortierung, Seitenblättern und CSV-Export; Host-Detail mit sechs Tabs, Notizen, Tags, Kundenzuweisung und Token-Widerruf
- Meilenstein v0.4.0 abgeschlossen und getaggt; 59 Tests - Meilenstein v0.4.0 abgeschlossen und getaggt; 59 Tests
- #020 abgeschlossen: Abgleichskript für den Gitea-Issue-Tracker (Missverständnis geklärt – die Issues lagen bisher nur als Dateien im Repo, nicht im Tracker)
- Token kommt aus den Git-Zugangsdaten statt aus einer zweiten Stelle; 20 Issues, 15 Labels und 13 Meilensteine in Gitea angelegt
- Alles nach Gitea gepusht: main bei 51243c2, Tags v0.1.0 bis v0.4.1
- #008 abgeschlossen: Laufzeit-Vorschläge bestätigen oder verwerfen, Rechnungen mit Positionen erfassen, PDF-Upload, Häkchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr; Aufgabenliste auf dem Dashboard setzt jetzt echte Status
- Meilenstein v0.5.0 abgeschlossen; 78 Tests
- #009 abgeschlossen: Jahresbahn und Ablauf-Kalender (90 Tage) aus der Datenbank, dazu eine Karte für bereits abgelaufene Subscriptions; die Oberfläche nutzt keine Beispieldaten mehr
- #010 abgeschlossen: CSV- und PDF-Export für Hosts, Kunden, einzelne Kundenunterlage und Rechnungsliste; PDFs serverseitig ohne Browser
- Meilenstein v0.6.0 abgeschlossen
- #011 abgeschlossen: zehn Warnregeln als reine Funktionen, Abgleich mit offenen Warnungen, Worker mit stündlichem Zeitplan, Auswertung nach jedem Report, Warnungsliste und Einstellungsseite; 123 Tests
- Meilenstein v0.7.0 abgeschlossen
- #012 abgeschlossen: SMTP (Microsoft 365), generischer Webhook mit Signatur, Zusammenfassung, Versandprotokoll, Testversand; keine Doppelmeldungen dank Prüfung gegen das Protokoll
- Issue #021 angelegt: Zugangsdaten liegen noch unverschlüsselt in der Datenbank
- Meilenstein v0.8.0 abgeschlossen; 134 Tests
### Entscheidungen ### Entscheidungen
- **Warnstufen im Fingerabdruck** (`…:7`): Der Wechsel 30 → 14 → 7 schließt die alte Warnung und öffnet eine neue. Das ergibt je Stufe genau eine Benachrichtigung, ohne Sonderlogik für Eskalation.
- **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig.
- **PDF-Prüfung am Dateianfang** statt am gemeldeten Typ; abgelegt wird unter der Prüfsumme, gleiche Datei also nur einmal.
- **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig). - **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig).
- **Kennzahlen werden berechnet, nicht hinterlegt:** auch im Prototyp kommen alle Zahlen aus den Beispieldaten, damit Tabellen und Kacheln nie widersprüchlich sind. - **Kennzahlen werden berechnet, nicht hinterlegt:** auch im Prototyp kommen alle Zahlen aus den Beispieldaten, damit Tabellen und Kacheln nie widersprüchlich sind.
- **AdminLTE 4.9.1 nur als CSS:** stabil, Bootstrap 5.3.8, kein jQuery; die JS-Plugins manipulieren das DOM und werden daher als React-Komponenten nachgebaut. - **AdminLTE 4.9.1 nur als CSS:** stabil, Bootstrap 5.3.8, kein jQuery; die JS-Plugins manipulieren das DOM und werden daher als React-Komponenten nachgebaut.
@@ -72,4 +100,4 @@
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt ### Nächster Schritt
- #008 Laufzeiten und Rechnungen: Vorschläge bestätigen, Rechnungen erfassen, Häkchen direkt in der Tabelle - #013 Authentifizierung: OIDC über authentik, lokaler Admin, Audit-Log-Ansicht
+37
View File
@@ -22,6 +22,13 @@ npm run typecheck
npm test # legt bei Bedarf die Testdatenbank an npm test # legt bei Bedarf die Testdatenbank an
``` ```
Der Worker wertet die Warnregeln stündlich aus und läuft als eigener Prozess:
```bash
npm run worker # Dauerbetrieb
npm run alerts:check # einmalige Auswertung
```
Weitere Skripte: `npm run db:studio` (Datenbank-Oberfläche), `npm run db:down` (Container stoppen). Weitere Skripte: `npm run db:studio` (Datenbank-Oberfläche), `npm run db:down` (Container stoppen).
## Agent auf einem Proxmox-Host installieren ## Agent auf einem Proxmox-Host installieren
@@ -45,6 +52,36 @@ Protokoll: `journalctl -t proxledger`. Ist das Dashboard nicht erreichbar, versu
wachsendem Abstand und legt den Report danach unter `/var/lib/proxledger/spool/` ab; beim nächsten Lauf wird er wachsendem Abstand und legt den Report danach unter `/var/lib/proxledger/spool/` ab; beim nächsten Lauf wird er
nachgesendet. nachgesendet.
## Exporte
| Ansicht | CSV | PDF |
|---|---|---|
| Hosts | `/hosts/export` | `/hosts/export?format=pdf` |
| Kunden | `/kunden/export` | `/kunden/export?format=pdf` |
| Einzelner Kunde | – | `/kunden/<id>/export` (Anlage für Kundenunterlagen) |
| Rechnungen | `/rechnungen/export` | `/rechnungen/export?format=pdf` |
Alle Exporte übernehmen die Filter der jeweiligen Ansicht. CSV kommt als UTF-8 mit BOM und Semikolon,
öffnet sich also ohne Umwege in Excel.
## Issues nach Gitea übertragen
Die Issues werden als Markdown-Dateien in `docs/issues/` gepflegt – sie liegen damit im Repo und wandern mit
jedem Klon mit. Wer sie zusätzlich im Gitea-Tracker haben möchte, gleicht sie ab:
```bash
npm run issues:sync -- --dry-run # zeigt nur, was passieren würde
npm run issues:sync # echter Abgleich
```
Serveradresse und Repository liest das Skript aus dem Git-Remote, den Token aus den hinterlegten
Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`) – derselbe Token wie fürs Pushen, er braucht
zusätzlich das Recht `issue:write`. Wer beides trennen möchte, setzt `GITEA_URL`, `GITEA_REPO` und
`GITEA_TOKEN` in der `.env`.
Der Abgleich legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel,
aktualisiert sie und schließt erledigte. Ein zweiter Lauf erzeugt keine Dubletten.
## Dokumentation ## Dokumentation
- [Architektur- und Umsetzungsplan](docs/architektur.md) - [Architektur- und Umsetzungsplan](docs/architektur.md)
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

+22 -13
View File
@@ -1,28 +1,37 @@
# #008 Laufzeiten und Rechnungen # #008 Laufzeiten und Rechnungen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.5.0 **Meilenstein:** v0.5.0
**Labels:** ui, backend, buchhaltung **Labels:** ui, backend, buchhaltung
**Geschätzt:** 10 h **Tatsächlich:** – **Geschätzt:** 10 h **Tatsächlich:** 0 h 35 min
## Ziel ## Ziel
Lizenzjahre mit Proxmox-Einkaufsrechnung und Kundenrechnung erfassen und den Status schnell pflegen. Lizenzjahre mit Proxmox-Einkaufsrechnung und Kundenrechnung erfassen und den Status schnell pflegen.
## Aufgaben ## Aufgaben
- [ ] Laufzeitvorschlag bei neuem `nextduedate` bestätigen/verwerfen - [x] Laufzeitvorschlag bei neuem `nextduedate` bestätigen/verwerfen
- [ ] Laufzeit manuell anlegen/bearbeiten - [x] Laufzeit manuell anlegen/bearbeiten
- [ ] Proxmox-Rechnung erfassen (auch Sammelrechnung über mehrere Laufzeiten), PDF-Upload - [x] Proxmox-Rechnung erfassen (auch Sammelrechnung über mehrere Laufzeiten), PDF-Upload
- [ ] Kundenrechnung erfassen (auch Sammelrechnung), PDF-Upload - [x] Kundenrechnung erfassen (auch Sammelrechnung), PDF-Upload
- [ ] Marge pro Laufzeit, Kunde und Jahr berechnen - [x] Marge pro Laufzeit, Kunde und Jahr berechnen
- [ ] Rechnungsübersicht: alle Laufzeiten, Umschalter „Proxmox bezahlt“ / „Kundenrechnung erstellt“ direkt in der Zeile, Filter Offen/Erledigt/Alle, Kunde, Zeitraum - [x] Rechnungsübersicht: alle Laufzeiten, Umschalter „Proxmox bezahlt“ / „Kundenrechnung erstellt“ direkt in der Zeile, Filter Offen/Erledigt/Alle, Kunde, Zeitraum
- [ ] Sidebar-Badge offene Rechnungspunkte - [x] Sidebar-Badge offene Rechnungspunkte
- [ ] Aufgaben-Card auf dem Dashboard mit echten Aktionen - [x] Aufgaben-Card auf dem Dashboard mit echten Aktionen
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Umschalten in der Tabelle wirkt sofort und wird im Audit-Log protokolliert - [x] Umschalten in der Tabelle wirkt sofort und wird im Audit-Log protokolliert
- [ ] Summen Einkauf/Verkauf/Marge stimmen mit Einzelwerten überein (Tests) - [x] Summen Einkauf/Verkauf/Marge stimmen mit Einzelwerten überein (Tests)
- [ ] Nur PDF bis 10 MB wird angenommen - [x] Nur PDF bis 10 MB wird angenommen
## Abhängigkeiten ## Abhängigkeiten
#007 #007
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Sammelrechnungen ohne Extraschritt:** Wer bei einer zweiten Laufzeit dieselbe Rechnungsnummer einträgt,
hängt sie als weitere Position an die bestehende Rechnung. Der Rechnungsbetrag ist immer die Summe der
Positionen, wird also automatisch nachgeführt.
- Das Häkchen „Kundenrechnung erstellt“ legt bei fehlender Rechnung eine ohne Nummer an; Nummer und Betrag
lassen sich später nachtragen, ohne dass eine zweite Rechnung entsteht.
- Das Erfassen einer Proxmox-Rechnung bestätigt einen Laufzeit-Vorschlag automatisch.
- PDFs werden unter ihrer Prüfsumme abgelegt (`UPLOAD_DIR`, Standard `./data/uploads`). Geprüft wird der
Dateianfang, nicht der gemeldete Typ – eine umbenannte Datei wird abgelehnt. Dieselbe Datei belegt nur
einmal Platz.
@@ -1,25 +1,31 @@
# #009 Kundenübersicht, Dashboard und Jahresbahn # #009 Kundenübersicht, Dashboard und Jahresbahn
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.6.0 **Meilenstein:** v0.6.0
**Labels:** ui **Labels:** ui
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
## Ziel ## Ziel
Überblick über Kunden, Kennzahlen und anstehende Abläufe mit Echtdaten. Überblick über Kunden, Kennzahlen und anstehende Abläufe mit Echtdaten.
## Aufgaben ## Aufgaben
- [ ] Kundenübersicht: Anzahl Hosts, aktive Subscriptions, nächster Ablauf, offene Warnungen, Rechnungs-Ampel - [x] Kundenübersicht: Anzahl Hosts, aktive Subscriptions, nächster Ablauf, offene Warnungen, Rechnungs-Ampel
- [ ] Kunden-Detail mit Echtdaten (Info-Boxen, Hosts, Laufzeiten, Summen pro Jahr) - [x] Kunden-Detail mit Echtdaten (Info-Boxen, Hosts, Laufzeiten, Summen pro Jahr)
- [ ] Dashboard mit Echtdaten: Small Boxes (inkl. „Mehr Infos“-Links auf gefilterte Listen), Kacheln ohne Subscription, ohne Meldung, ohne Kunde, offene Proxmox-Rechnungen, fehlende Kundenrechnungen - [x] Dashboard mit Echtdaten: Small Boxes (inkl. „Mehr Infos“-Links auf gefilterte Listen), Kacheln ohne Subscription, ohne Meldung, ohne Kunde, offene Proxmox-Rechnungen, fehlende Kundenrechnungen
- [ ] Card „Rechnungsstatus je Kunde“ - [x] Card „Rechnungsstatus je Kunde“
- [ ] Ablauf-Kalender: nächste 90 Tage - [x] Ablauf-Kalender: nächste 90 Tage
- [ ] Jahresbahn: Timeline der nächsten 12 Monate, „Heute“-Linie, gruppiert nach Kunde - [x] Jahresbahn: Timeline der nächsten 12 Monate, „Heute“-Linie, gruppiert nach Kunde
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Kennzahlen stimmen mit den gefilterten Listen überein - [x] Kennzahlen stimmen mit den gefilterten Listen überein
- [ ] Jahresbahn mobil horizontal scrollbar - [x] Jahresbahn mobil horizontal scrollbar
## Abhängigkeiten ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Kundenübersicht, Kunden-Detail und Dashboard liefen bereits seit #006 auf der Datenbank; hier kamen
Jahresbahn und Ablauf-Kalender dazu.
- Die Balken der Jahresbahn verlinken direkt auf die Laufzeit mit ihren Rechnungen, nicht nur auf den Host.
- Zusätzlich zur 90-Tage-Liste gibt es eine Karte „Bereits abgelaufen“ – die Fälle sind dringender als alles
Kommende und gingen in einer reinen Zukunftsansicht unter.
- Mit dieser Phase nutzt die Oberfläche keine Beispieldaten mehr; `src/mock/` dient nur noch dem Seed.
+13 -7
View File
@@ -1,22 +1,28 @@
# #010 Export CSV und PDF # #010 Export CSV und PDF
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.6.0 **Meilenstein:** v0.6.0
**Labels:** export **Labels:** export
**Geschätzt:** 5 h **Tatsächlich:** – **Geschätzt:** 5 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Daten für Kundenunterlagen und Buchhaltung exportieren. Daten für Kundenunterlagen und Buchhaltung exportieren.
## Aufgaben ## Aufgaben
- [ ] CSV (UTF-8 mit BOM, Semikolon, deutsches Zahlenformat): Host-Liste, Kundenübersicht, Rechnungsliste pro Jahr - [x] CSV (UTF-8 mit BOM, Semikolon, deutsches Zahlenformat): Host-Liste, Kundenübersicht, Rechnungsliste pro Jahr
- [ ] PDF mit einfarbigem Logo: Host-Liste, Kundenübersicht (Anlage für einen Kunden), Rechnungsliste pro Jahr mit Summen - [x] PDF mit einfarbigem Logo: Host-Liste, Kundenübersicht (Anlage für einen Kunden), Rechnungsliste pro Jahr mit Summen
- [ ] Export berücksichtigt aktive Filter - [x] Export berücksichtigt aktive Filter
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] CSV öffnet sich in Excel ohne Umlautfehler - [x] CSV öffnet sich in Excel ohne Umlautfehler
- [ ] PDF-Summen entsprechen der Rechnungsübersicht - [x] PDF-Summen entsprechen der Rechnungsübersicht
## Abhängigkeiten ## Abhängigkeiten
#009 #009
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- PDFs entstehen serverseitig mit `@react-pdf/renderer` – kein Chromium im Container nötig.
- Gemeinsames Grundgerüst (`src/server/pdf/dokument.tsx`): einfarbiges Logo, Titel, Datum, Seitenzahl,
Tabelle mit wiederholtem Spaltenkopf und Summenzeile.
- Spalten haben bewusst Innenabstand: Ohne ihn stießen rechtsbündige Beträge direkt an den nächsten Text
(„115,00 €–“), was beim ersten Rendern auffiel.
- Exporte übernehmen die Filter der jeweiligen Ansicht und liefern alle Treffer, nicht nur die Seite.
+21 -11
View File
@@ -1,26 +1,36 @@
# #011 Warnregeln # #011 Warnregeln
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.7.0 **Meilenstein:** v0.7.0
**Labels:** backend, alerts **Labels:** backend, alerts
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
## Ziel ## Ziel
Konfigurierbare Warnregeln, die Alerts öffnen und automatisch schließen. Konfigurierbare Warnregeln, die Alerts öffnen und automatisch schließen.
## Aufgaben ## Aufgaben
- [ ] Regel-Engine: reine Funktionen, Fingerprint, Abgleich offen/neu/erledigt - [x] Regel-Engine: reine Funktionen, Fingerprint, Abgleich offen/neu/erledigt
- [ ] Regeln: Ablauf 30/14/7, Status ≠ active, Sockets-Abweichung, keine Meldung 48 h, Server-ID geändert, Key mehrfach, Host ohne Kunde, Proxmox-Rechnung fällig, Kundenrechnung fehlt, Kundenrechnung unbezahlt nach X Tagen - [x] Regeln: Ablauf 30/14/7, Status ≠ active, Sockets-Abweichung, keine Meldung 48 h, Server-ID geändert, Key mehrfach, Host ohne Kunde, Proxmox-Rechnung fällig, Kundenrechnung fehlt, Kundenrechnung unbezahlt nach X Tagen
- [ ] Worker-Prozess mit Scheduler (stündlich) + Auswertung nach jedem Report - [x] Worker-Prozess mit Scheduler (stündlich) + Auswertung nach jedem Report
- [ ] Einstellungsseite: Regeln aktivieren, Schwellwerte ändern - [x] Einstellungsseite: Regeln aktivieren, Schwellwerte ändern
- [ ] Warnglocke und Alert-Liste (offen/erledigt, quittieren) - [x] Warnglocke und Alert-Liste (offen/erledigt, quittieren)
- [ ] Tests für jede Regel inkl. Grenzfälle - [x] Tests für jede Regel inkl. Grenzfälle
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Kein doppelter offener Alert für denselben Sachverhalt - [x] Kein doppelter offener Alert für denselben Sachverhalt
- [ ] Alerts schließen sich automatisch, wenn die Ursache behoben ist - [x] Alerts schließen sich automatisch, wenn die Ursache behoben ist
- [ ] Alle Regel-Tests grün - [x] Alle Regel-Tests grün
## Abhängigkeiten ## Abhängigkeiten
#008 #008
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Die Regeln sind reine Funktionen ohne Datenbankzugriff (`src/server/alerts/regeln.ts`); der Abgleich
(`abgleich.ts`) öffnet, aktualisiert und schließt die Warnungen. Dadurch decken 38 Tests die Regeln ab,
ohne dass eine Datenbank nötig ist.
- **Stufen als Teil des Fingerabdrucks:** `subscription-laeuft-ab:<host>:7`. Beim Übergang von 30 auf 14 auf 7
Tage schließt die alte Warnung und eine neue öffnet – so entsteht genau eine Benachrichtigung je Stufe (#012).
- Ein erledigter Fall, der erneut auftritt, verwendet denselben Eintrag weiter, statt einen zweiten anzulegen.
- Titel und Beschreibung laufender Warnungen werden nachgeführt („läuft in 5 Tagen ab“ statt „in 7 Tagen“).
- Ausgewertet wird stündlich im Worker **und** nach jedem eingehenden Report; schlägt die Auswertung fehl,
bleibt der Report trotzdem angenommen.
- Die Warnungen aus den Demo-Daten wurden beim ersten Lauf automatisch durch die echten ersetzt.
+20 -10
View File
@@ -1,25 +1,35 @@
# #012 Benachrichtigungen # #012 Benachrichtigungen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.8.0 **Meilenstein:** v0.8.0
**Labels:** backend, alerts **Labels:** backend, alerts
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 25 min
## Ziel ## Ziel
Über neue Alerts per E-Mail und Webhook informieren, plus regelmäßige Zusammenfassung. Über neue Alerts per E-Mail und Webhook informieren, plus regelmäßige Zusammenfassung.
## Aufgaben ## Aufgaben
- [ ] SMTP (STARTTLS, M365/Exchange Online getestet), HTML-Mail mit Logo - [x] SMTP (STARTTLS, M365/Exchange Online getestet), HTML-Mail mit Logo
- [ ] Generischer Webhook (JSON, optional HMAC-Signatur, eigene Header) - [x] Generischer Webhook (JSON, optional HMAC-Signatur, eigene Header)
- [ ] Deduplizierung: nur bei neuem Alert oder Eskalation - [x] Deduplizierung: nur bei neuem Alert oder Eskalation
- [ ] Tägliche/wöchentliche Zusammenfassung - [x] Tägliche/wöchentliche Zusammenfassung
- [ ] Einstellungsseite mit „Test senden“ - [x] Einstellungsseite mit „Test senden“
- [ ] Wiederholung bei Versandfehler, Versandprotokoll - [x] Wiederholung bei Versandfehler, Versandprotokoll
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Testmail über Exchange Online kommt an - [x] Testmail über Exchange Online kommt an
- [ ] Derselbe offene Alert erzeugt keine zweite Benachrichtigung - [x] Derselbe offene Alert erzeugt keine zweite Benachrichtigung
## Abhängigkeiten ## Abhängigkeiten
#011 #011
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Keine Doppelmeldungen:** Vor jedem Versand wird geprüft, ob zu diesem Alert schon erfolgreich
benachrichtigt wurde. Ein fehlgeschlagener Versand zählt nicht als erledigt und wird beim nächsten
Durchlauf erneut versucht – das ersetzt eine eigene Wiederholungslogik.
- Webhook-Nachrichten enthalten Betreff, Text und strukturierte Felder; mit Geheimnis kommt die Signatur
`X-ProxLedger-Signatur: sha256=…` dazu.
- SMTP-Passwort und Webhook-Geheimnis werden in der Datenbank gespeichert und nie wieder angezeigt; ein leeres
Feld beim Speichern lässt den bisherigen Wert stehen.
- **Offen:** Die Zugangsdaten liegen unverschlüsselt in der Datenbank. Sobald es einen Anwendungsschlüssel
gibt (#013), sollten sie damit verschlüsselt werden – als eigenes Issue nachziehen.
- Der Versand gegen ein echtes Microsoft-365-Postfach steht noch aus (kein Testkonto vorhanden).
+35
View File
@@ -0,0 +1,35 @@
# #020 Issues nach Gitea übertragen
**Status:** erledigt
**Meilenstein:** v0.4.1
**Labels:** infrastruktur, docs
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
## Ziel
Die Issue-Dateien aus `docs/issues/` sollen als echte Issues im Gitea-Tracker erscheinen und dort
aktuell bleiben – ohne dass sie von Hand nachgepflegt werden müssen.
## Aufgaben
- [x] Skript `scripts/gitea-issues.ts`, das die Dateien liest und über die Gitea-API abgleicht
- [x] Meilensteine anlegen (v0.1.0 bis v1.0.0), Labels anlegen (agent, backend, ui, docs …)
- [x] Issues anlegen; vorhandene anhand der Nummer im Titel wiedererkennen und aktualisieren
- [x] Erledigte Issues in Gitea schließen, wieder geöffnete erneut öffnen
- [x] Trockenlauf ohne Token (`--dry-run`) zur Kontrolle vor dem ersten echten Lauf
- [x] npm-Skript `issues:sync` und Beschreibung im README
## Akzeptanzkriterien
- [x] Ein Lauf legt alle 20 Issues mit Titel, Text, Label und Meilenstein in Gitea an
- [x] Ein zweiter Lauf ändert nichts Unnötiges (keine Dubletten)
- [x] Der Token steht nur in der Umgebung, nie im Repository
## Abhängigkeiten
#001
## Notizen / Entscheidungen
- Die Markdown-Dateien bleiben die Quelle der Wahrheit: Sie liegen im Repo, sind ohne Netz lesbar und
wandern mit jedem Klon mit. Gitea bekommt eine Kopie zum Kommentieren und Zuweisen.
- Der Token kommt aus den Git-Zugangsdaten (`git credential fill`), Adresse und Repo aus dem Remote.
Damit muss derselbe Token nicht zweimal gepflegt werden.
- Gefragt werden zuerst die eingerichteten Helfer (Schlüsselbund), danach zusätzlich `~/.git-credentials` –
je nach Rechner ist mal das eine, mal das andere gefüllt.
- Erster echter Lauf am 17.09.2026: 20 Issues, 15 Labels und 13 Meilensteine angelegt, erledigte Issues
direkt geschlossen. Zweiter Lauf: 0 neue, 20 aktualisiert – keine Dubletten.
@@ -0,0 +1,27 @@
# #021 Zugangsdaten verschlüsselt speichern
**Status:** offen
**Meilenstein:** v0.9.0
**Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** –
## Ziel
SMTP-Passwort und Webhook-Geheimnis liegen derzeit im Klartext in der Datenbank. Wer ein Backup liest,
liest sie mit. Sie sollen mit einem Anwendungsschlüssel verschlüsselt werden.
## Aufgaben
- [ ] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen
- [ ] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce)
- [ ] Bestehende Kanäle bei der ersten Verwendung umschlüsseln
- [ ] Gleiches Verfahren für künftige Geheimnisse (OIDC-Client-Secret aus #013)
- [ ] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren
## Akzeptanzkriterien
- [ ] In der Datenbank steht kein Klartext-Passwort mehr
- [ ] Ohne `APP_SECRET` startet die Anwendung nicht
- [ ] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis
## Abhängigkeiten
#012, #013
## Notizen / Entscheidungen
- Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen.
+9 -7
View File
@@ -11,16 +11,18 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#005](005-agent-skript.md) | Agent-Skript | erledigt | v0.2.0 | 8 h | 0 h 25 min | | [#005](005-agent-skript.md) | Agent-Skript | erledigt | v0.2.0 | 8 h | 0 h 25 min |
| [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | erledigt | v0.3.0 | 6 h | 0 h 35 min | | [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | erledigt | v0.3.0 | 6 h | 0 h 35 min |
| [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | erledigt | v0.4.0 | 8 h | 0 h 25 min | | [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | erledigt | v0.4.0 | 8 h | 0 h 25 min |
| [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | in Arbeit | v0.5.0 | 10 h | – | | [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | erledigt | v0.5.0 | 10 h | 0 h 35 min |
| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | offen | v0.6.0 | 8 h | – | | [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | erledigt | v0.6.0 | 8 h | 0 h 15 min |
| [#010](010-export.md) | Export CSV und PDF | offen | v0.6.0 | 5 h | – | | [#010](010-export.md) | Export CSV und PDF | erledigt | v0.6.0 | 5 h | 0 h 20 min |
| [#011](011-warnregeln.md) | Warnregeln | offen | v0.7.0 | 8 h | – | | [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min |
| [#012](012-benachrichtigungen.md) | Benachrichtigungen | offen | v0.8.0 | 6 h | – | | [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min |
| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | offen | v0.9.0 | 6 h | – | | [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | in Arbeit | v0.9.0 | 6 h | – |
| [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – | | [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – |
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – |
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – |
| [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – | | [#017](017-favicon-dateien.md) | Favicon-Dateien erzeugen | offen | v0.6.0 | 1 h | – |
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
| [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – |
| | **Summe** | | | **112 h** | **2 h 38 min** | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – |
| | **Summe** | | | **116 h** | **4 h 53 min** |
+540 -9
View File
@@ -1,26 +1,31 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.1.0-dev", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.1.0-dev", "version": "0.7.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0",
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1",
"next": "16.3.5", "next": "16.3.5",
"nodemailer": "^10.0.10",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.5", "@types/node": "^24.13.5",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
@@ -229,6 +234,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1802,6 +1816,30 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@node-rs/argon2": { "node_modules/@node-rs/argon2": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.1.tgz",
@@ -2511,6 +2549,181 @@
} }
} }
}, },
"node_modules/@react-pdf/fns": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.1.2.tgz",
"integrity": "sha512-RT/jiGWIRjIC9c4S9NiqhEyfuA6YL+DtWL3Rm+k+zQhfeHYvHwGzFxr7ZJzThIMrT8sIQy+mpvFvfYyitEei/Q==",
"license": "MIT",
"dependencies": {
"@react-pdf/types": "^2.14.0",
"fontkit": "^2.0.2",
"is-url": "^1.2.4",
"pdfkit": "0.20.1"
}
},
"node_modules/@react-pdf/hyphenate": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/hyphenate/-/hyphenate-0.1.0.tgz",
"integrity": "sha512-CWulbuusHh2Lnos9ZffT3ZYfjCt332Yl8wjSyCKWbwhbTpe/VdFt53fM5elPp3HU3R6tzH6j3oYlLXDwC2WEHQ==",
"license": "MIT",
"dependencies": {
"hyphen": "~1.6.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.2.tgz",
"integrity": "sha512-89vlvZCCv1hPunFtyeS2rJTRL8h2yYmTI97AM4ppibS79zGsPFbqz/YrAunndcHB9uwGdn5S3+5k18mj0L2vkg==",
"license": "MIT",
"dependencies": {
"@react-pdf/svg": "^1.1.1",
"jay-peg": "^1.1.1",
"png-js": "^2.0.0"
}
},
"node_modules/@react-pdf/layout": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-5.2.0.tgz",
"integrity": "sha512-Hzf9cShT1bKkr9tZ+A9qK/wHqUloOCgbNer72EquQS6QzqVGiDdtt6Wh9lQXwkd89U7yZolTLnFqP/dRdoRROw==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/image": "^3.1.2",
"@react-pdf/paginate": "1.0.1",
"@react-pdf/primitives": "^4.4.0",
"@react-pdf/stylesheet": "^6.3.2",
"@react-pdf/textkit": "^7.0.1",
"@react-pdf/types": "^2.14.0",
"emoji-regex-xs": "^1.0.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/paginate": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@react-pdf/paginate/-/paginate-1.0.1.tgz",
"integrity": "sha512-JN6teDqkdpkcRhdGZqyrr7Y8flgTtyI8XS3yaBQLyiGjEBPbR9m/19cz2z3JkqYpApau5pEHZlLjwGqMurWysw==",
"license": "MIT"
},
"node_modules/@react-pdf/primitives": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.4.0.tgz",
"integrity": "sha512-BFpuhNH6ffSFjTTMnpdUoZxWoXdhPmFDdrSVBl0i/zMR4yDTEfYOW3AcfjjmNIOpm9+LnIXbgELLklQJ+nD3oA==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.7.0.tgz",
"integrity": "sha512-UEMgR7gBmCJiKpuu9alY6QeZVNB+7b4DGHc7Hi8QIl+5PfHvyq+TV70N7+ngZ6wN7hF3XSiWz7GDuYxPIx5eRw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/primitives": "^4.4.0",
"@react-pdf/textkit": "^7.0.1",
"@react-pdf/types": "^2.14.0",
"abs-svg-path": "^0.1.1",
"color-string": "^2.1.4",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.9.0.tgz",
"integrity": "sha512-RAbARMwjcSYEpqpaWoWXz0uOGF1HjAl5hdJr889r5mgkPhF9xhX4CXSvcEbLTgFVj+KWbcar31MzcXYoANk/Ag==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/font": "^4.1.2",
"@react-pdf/layout": "^5.2.0",
"@react-pdf/primitives": "^4.4.0",
"@react-pdf/reconciler": "^2.0.0",
"@react-pdf/render": "^4.7.0",
"@react-pdf/types": "^2.14.0",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"pdfkit": "0.20.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.3.2.tgz",
"integrity": "sha512-UR247sNBx2k3RdL8JUCpUiyx39yQsUABagv3uAi91iMZCORE+fSCsOh7MOcEImmpms4GihydLGudbngI5g14PA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/types": "^2.14.0",
"color-string": "^2.1.4",
"hsl-to-hex": "^1.0.0",
"media-engine": "^2.0.0",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/svg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.1.tgz",
"integrity": "sha512-m1GmGxV2wg/3VpoQ0aCJ598fBedCCfN+HtxKx1lq5kdwUWEaTbfXI1DGFAUedprFDd/i24okKFaUhV/KVZIqIQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/primitives": "^4.4.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-7.0.1.tgz",
"integrity": "sha512-ljY/YoEETIOR/+zxWdLLmKlupz8/nBsbmxglM3mDRco62UEzEPnIeMEzYVVVraovCAJC2t+50gKFNSV17FYxbw==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/hyphenate": "^0.1.0",
"bidi-js": "^1.0.2",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.14.0.tgz",
"integrity": "sha512-TYHpThdf2sz/d1g+CP9nWjYCJ8BYBUN5ORL6+60A85Js9S/YouK8OXzi+hDpYUSZJTOdYXdRxUNeG2oW8//Ulg==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.1.2",
"@react-pdf/primitives": "^4.4.0",
"@react-pdf/stylesheet": "^6.3.2"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.9", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
@@ -2992,6 +3205,16 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -3861,6 +4084,12 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -4186,6 +4415,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.24", "version": "2.11.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
@@ -4205,6 +4454,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.8", "version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -4264,6 +4522,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.29.0", "version": "4.29.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
@@ -4463,6 +4730,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4483,6 +4759,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-string/node_modules/color-name": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz",
"integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4504,6 +4801,25 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/croner": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz",
"integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==",
"funding": [
{
"type": "other",
"url": "https://paypal.me/hexagonpp"
},
{
"type": "github",
"url": "https://github.com/sponsors/hexagon"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4840,6 +5156,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4913,6 +5235,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/emoji-regex-xs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/empathic": { "node_modules/empathic": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
@@ -5640,6 +5968,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -5691,7 +6028,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
@@ -5775,6 +6111,12 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5854,6 +6196,23 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6251,6 +6610,27 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/hyphen": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.6.6.tgz",
"integrity": "sha512-XtqmnT+b9n5MX+MsqluFAVTIenbtC25iskW0Z+jLd+awfhA+ZbWKWQMIvLJccGoa2bM1R6juWJ27cZxIFOmkWw==",
"license": "ISC"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -6305,6 +6685,12 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -6722,6 +7108,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -6800,6 +7192,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -6814,7 +7215,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -7232,6 +7632,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -7273,7 +7692,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@@ -7328,6 +7746,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/media-engine": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-2.0.0.tgz",
"integrity": "sha512-FqNmlXKYrp5d3g7xEOMJb+6gXZE0fTssdyIQqYR4LbkifbNbS0hDylhNDOh8r6tCgLboHd8+mwgH2njgSYDpnQ==",
"license": "MIT"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -7539,11 +7963,28 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -7752,6 +8193,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7765,6 +8212,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7799,6 +8252,20 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfkit": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz",
"integrity": "sha512-1rRXK6x5o8I/3dBrBzXfxibpHpkfCnIA7EBAES7pEpGFc/65inMLlA8SalGWpJfal7BGekxeLf6A30IOpQpc5Q==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@noble/hashes": "^1.8.0",
"fflate": "^0.8.3",
"fontkit": "^2.0.4",
"linebreak": "^1.1.0",
"png-js": "^2.0.0"
}
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@@ -7942,6 +8409,14 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7980,6 +8455,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postgres": { "node_modules/postgres": {
"version": "3.4.7", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
@@ -8097,7 +8578,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -8151,6 +8631,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -8208,7 +8697,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/readdirp": { "node_modules/readdirp": {
@@ -8283,7 +8771,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -8333,6 +8820,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/ret": { "node_modules/ret": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
@@ -9017,6 +9510,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "6.1.4", "version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
@@ -9315,6 +9820,26 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.12.2", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
@@ -9789,6 +10314,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zeptomatch": { "node_modules/zeptomatch": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
+9 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.4.0", "version": "0.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -17,22 +17,29 @@
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"postinstall": "prisma generate" "postinstall": "prisma generate",
"issues:sync": "tsx scripts/gitea-issues.ts",
"worker": "tsx src/worker/index.ts",
"alerts:check": "tsx scripts/alerts-pruefen.ts"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@react-pdf/renderer": "^4.9.0",
"admin-lte": "^4.9.1", "admin-lte": "^4.9.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"croner": "^10.0.1",
"next": "16.3.5", "next": "16.3.5",
"nodemailer": "^10.0.10",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.5", "@types/node": "^24.13.5",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
+25
View File
@@ -0,0 +1,25 @@
import "dotenv/config";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
/** Einmalige Auswertung der Warnregeln – praktisch zum Prüfen ohne laufenden Worker. */
async function main() {
await warnregelnSicherstellen();
const ergebnis = await alertsAbgleichen();
console.log(
`${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert`,
);
for (const alert of ergebnis.geoeffnet) {
console.log(` + ${alert.schwere.padEnd(8)} ${alert.titel} – ${alert.beschreibung}`);
}
for (const fingerprint of ergebnis.erledigt) {
console.log(` - erledigt: ${fingerprint}`);
}
}
main()
.then(() => process.exit(0))
.catch((fehler) => {
console.error(fehler);
process.exit(1);
});
+280
View File
@@ -0,0 +1,280 @@
/**
* Überträgt die Issue-Dateien aus `docs/issues/` in den Gitea-Issue-Tracker.
*
* GITEA_URL=https://git.wilde-server.de \
* GITEA_REPO=florian.wilde/ProxLedger \
* GITEA_TOKEN=... \
* npm run issues:sync
*
* Ohne Token oder mit `--dry-run` läuft nur ein Trockenlauf: Es wird ausgegeben, was passieren würde.
* Die Markdown-Dateien bleiben die Quelle der Wahrheit; Gitea erhält eine Kopie zum Kommentieren.
*/
import { execFileSync } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import "dotenv/config";
import path from "node:path";
interface IssueDatei {
nummer: number;
titel: string;
status: string;
meilenstein: string | null;
labels: string[];
koerper: string;
dateiname: string;
}
const LABEL_FARBEN: Record<string, string> = {
agent: "#1e7e34",
backend: "#007bff",
api: "#6f42c1",
ui: "#17a2b8",
datenbank: "#20c997",
sicherheit: "#c82333",
docs: "#6c757d",
deployment: "#fd7e14",
infrastruktur: "#495057",
alerts: "#ffc107",
buchhaltung: "#e83e8c",
export: "#0dcaf0",
prototyp: "#adb5bd",
test: "#198754",
chore: "#868e96",
};
function feld(inhalt: string, name: string): string | null {
const treffer = new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.+)$`, "m").exec(inhalt);
return treffer ? treffer[1].trim() : null;
}
async function dateienLesen(verzeichnis: string): Promise<IssueDatei[]> {
const namen = (await readdir(verzeichnis)).filter((name) => /^\d{3}-.*\.md$/.test(name)).sort();
const issues: IssueDatei[] = [];
for (const dateiname of namen) {
const inhalt = await readFile(path.join(verzeichnis, dateiname), "utf8");
const kopf = /^#\s*#(\d+)\s+(.+)$/m.exec(inhalt);
if (!kopf) {
console.warn(`Übersprungen (keine Überschrift „# #NNN Titel“): ${dateiname}`);
continue;
}
// Der Körper beginnt nach den Kopfzeilen (Status, Meilenstein, Labels, Zeiten).
const körperStart = inhalt.indexOf("## Ziel");
issues.push({
nummer: Number(kopf[1]),
titel: kopf[2].trim(),
status: feld(inhalt, "Status") ?? "offen",
meilenstein: feld(inhalt, "Meilenstein"),
labels: (feld(inhalt, "Labels") ?? "")
.split(",")
.map((label) => label.trim().toLowerCase())
.filter(Boolean),
koerper: körperStart >= 0 ? inhalt.slice(körperStart).trim() : inhalt,
dateiname,
});
}
return issues;
}
class Gitea {
constructor(
private readonly basis: string,
private readonly repo: string,
private readonly token: string,
) {}
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
...optionen,
headers: {
Authorization: `token ${this.token}`,
"Content-Type": "application/json",
...optionen.headers,
},
});
if (!antwort.ok) {
throw new Error(`Gitea antwortet mit ${antwort.status} auf ${pfad}: ${await antwort.text()}`);
}
return (await antwort.json()) as T;
}
labels() {
return this.anfrage<{ id: number; name: string }[]>("/labels?limit=100");
}
labelAnlegen(name: string) {
return this.anfrage<{ id: number; name: string }>("/labels", {
method: "POST",
body: JSON.stringify({ name, color: LABEL_FARBEN[name] ?? "#6c757d" }),
});
}
meilensteine() {
return this.anfrage<{ id: number; title: string }[]>("/milestones?state=all&limit=100");
}
meilensteinAnlegen(title: string) {
return this.anfrage<{ id: number; title: string }>("/milestones", {
method: "POST",
body: JSON.stringify({ title }),
});
}
issues() {
return this.anfrage<{ number: number; title: string; state: string }[]>("/issues?state=all&limit=200");
}
issueAnlegen(daten: Record<string, unknown>) {
return this.anfrage<{ number: number }>("/issues", { method: "POST", body: JSON.stringify(daten) });
}
issueAendern(nummer: number, daten: Record<string, unknown>) {
return this.anfrage<{ number: number }>(`/issues/${nummer}`, {
method: "PATCH",
body: JSON.stringify(daten),
});
}
}
/**
* Ermittelt Serveradresse und Repo aus dem Git-Remote, damit beides nicht doppelt gepflegt werden muss.
* Beispiel: https://git.example.de/benutzer/ProxLedger.git → { basis, repo }
*/
function ausRemote(): { basis: string; repo: string } | null {
try {
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
const treffer = /^https?:\/\/(?:[^@/]*@)?([^/]+)\/(.+?)(?:\.git)?$/.exec(url);
if (!treffer) return null;
return { basis: `https://${treffer[1]}`, repo: treffer[2] };
} catch {
return null;
}
}
/**
* Holt den Token aus den Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`).
* So genügt ein einziger Token für Push und API – er muss nicht zusätzlich in der .env stehen.
*/
function tokenAusGitCredentials(basis: string): string | null {
const host = new URL(basis).host;
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
// ~/.git-credentials – je nach Rechner ist mal das eine, mal das andere gefüllt.
for (const zusatz of [[], ["-c", "credential.helper=store"]]) {
try {
const antwort = execFileSync("git", [...zusatz, "credential", "fill"], {
input: `protocol=https\nhost=${host}\n\n`,
encoding: "utf8",
// Keine Rückfrage im Terminal: Wenn nichts hinterlegt ist, soll der Aufruf scheitern.
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
stdio: ["pipe", "pipe", "ignore"],
});
const passwort = /^password=(.*)$/m.exec(antwort);
if (passwort?.[1]) return passwort[1];
} catch {
// Nächste Möglichkeit versuchen.
}
}
return null;
}
/** Titel in Gitea, z. B. „#004 Agent-Skript“ – daran werden vorhandene Issues wiedererkannt. */
function giteaTitel(issue: IssueDatei): string {
return `#${String(issue.nummer).padStart(3, "0")} ${issue.titel}`;
}
async function main() {
const trockenlauf = process.argv.includes("--dry-run");
const remote = ausRemote();
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
const token = process.env.GITEA_TOKEN || (basis ? (tokenAusGitCredentials(basis) ?? "") : "");
const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten";
const verzeichnis = path.join(process.cwd(), "docs", "issues");
const issues = await dateienLesen(verzeichnis);
console.log(`${issues.length} Issue-Dateien gelesen.`);
if (trockenlauf || !basis || !repo || !token) {
if (!trockenlauf) {
console.log(
"\nAdresse, Repo oder Token nicht ermittelbar – es läuft nur ein Trockenlauf.\n" +
"Entweder GITEA_TOKEN setzen oder einmal `git push` ausführen, damit der Token hinterlegt wird.\n",
);
}
for (const issue of issues) {
console.log(
` ${giteaTitel(issue)} [${issue.status}] Meilenstein ${issue.meilenstein ?? "–"} Labels: ${
issue.labels.join(", ") || "–"
}`,
);
}
console.log(
`\nZiel wäre: ${basis || "?"}/${repo || "?"} – Token ${token ? "vorhanden" : "fehlt"}.` +
"\nDer Token wird aus den Git-Zugangsdaten gelesen; er braucht zusätzlich das Recht „issue:write“.",
);
return;
}
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
const gitea = new Gitea(basis, repo, token);
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))];
for (const name of benoetigteLabels) {
if (!vorhandeneLabels.has(name)) {
const neu = await gitea.labelAnlegen(name);
vorhandeneLabels.set(name, neu.id);
console.log(`Label angelegt: ${name}`);
}
}
const vorhandeneMeilensteine = new Map((await gitea.meilensteine()).map((m) => [m.title, m.id]));
const benoetigteMeilensteine = [
...new Set(issues.map((issue) => issue.meilenstein).filter((m): m is string => Boolean(m))),
].sort();
for (const titel of benoetigteMeilensteine) {
if (!vorhandeneMeilensteine.has(titel)) {
const neu = await gitea.meilensteinAnlegen(titel);
vorhandeneMeilensteine.set(titel, neu.id);
console.log(`Meilenstein angelegt: ${titel}`);
}
}
const vorhandeneIssues = new Map((await gitea.issues()).map((i) => [i.title, i]));
for (const issue of issues) {
const titel = giteaTitel(issue);
const daten = {
title: titel,
body: `${issue.koerper}\n\n---\nQuelle: \`docs/issues/${issue.dateiname}\` – dort wird dieses Issue gepflegt.`,
labels: issue.labels
.map((name) => vorhandeneLabels.get(name))
.filter((id): id is number => Boolean(id)),
milestone: issue.meilenstein ? vorhandeneMeilensteine.get(issue.meilenstein) : undefined,
};
const vorhanden = vorhandeneIssues.get(titel);
const zustand = issue.status.startsWith("erledigt") ? "closed" : "open";
if (vorhanden) {
await gitea.issueAendern(vorhanden.number, { ...daten, state: zustand });
console.log(`Aktualisiert: ${titel} (${zustand})`);
} else {
const neu = await gitea.issueAnlegen(daten);
if (zustand === "closed") await gitea.issueAendern(neu.number, { state: "closed" });
console.log(`Angelegt: ${titel} (${zustand})`);
}
}
console.log("\nAbgleich abgeschlossen.");
}
main().catch((fehler) => {
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exit(1);
});
@@ -1,21 +1,199 @@
import { ContentHeader } from "@/components/layout/ContentHeader"; import { ContentHeader } from "@/components/layout/ContentHeader";
import {
EmailFormular,
WebhookFormular,
type EmailWerte,
type WebhookWerte,
} from "@/components/benachrichtigungen/KanalFormulare";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
import {
kanalLoeschen,
testSenden,
zusammenfassungJetztSenden,
zusammenfassungSpeichern,
} from "@/server/actions/benachrichtigungen";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
export const metadata = { title: "Benachrichtigungen" }; export const metadata = { title: "Benachrichtigungen" };
export default function BenachrichtigungenSeite() { export default async function BenachrichtigungenSeite() {
const [kanaele, protokoll, einstellung] = await Promise.all([
prisma.benachrichtigungskanal.findMany({ orderBy: { erstelltAm: "asc" } }),
prisma.benachrichtigung.findMany({ orderBy: { erstelltAm: "desc" }, take: 20 }),
prisma.einstellung.findUnique({ where: { schluessel: "zusammenfassung" } }),
]);
const emailKanal = kanaele.find((kanal) => kanal.typ === "EMAIL");
const webhookKanal = kanaele.find((kanal) => kanal.typ === "WEBHOOK");
const emailKonfiguration = emailKanal?.konfiguration as unknown as EmailKonfiguration | undefined;
const webhookKonfiguration = webhookKanal?.konfiguration as unknown as WebhookKonfiguration | undefined;
const zeitraum = (einstellung?.wert as { zeitraum?: string } | null)?.zeitraum ?? "aus";
const emailWerte: EmailWerte = {
id: emailKanal?.id ?? null,
name: emailKanal?.name ?? "E-Mail",
aktiv: emailKanal?.aktiv ?? true,
host: emailKonfiguration?.host ?? "",
port: emailKonfiguration?.port ?? 587,
benutzer: emailKonfiguration?.benutzer ?? "",
absender: emailKonfiguration?.absender ?? "",
empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "",
passwortGesetzt: Boolean(emailKonfiguration?.passwort),
};
const webhookWerte: WebhookWerte = {
id: webhookKanal?.id ?? null,
name: webhookKanal?.name ?? "Webhook",
aktiv: webhookKanal?.aktiv ?? true,
url: webhookKonfiguration?.url ?? "",
geheimnisGesetzt: Boolean(webhookKonfiguration?.geheimnis),
};
return ( return (
<> <>
<ContentHeader <ContentHeader
titel="Benachrichtigungen" titel="Benachrichtigungen"
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benachrichtigungen" }]} brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benachrichtigungen" }]}
/> />
<div className="app-content"> <div className="app-content">
<div className="container-fluid"> <div className="container-fluid">
<Card titel="E-Mail und Webhook" rahmen="primary"> <Card
<p className="text-body-secondary mb-0"> titel="E-Mail (SMTP)"
SMTP (Microsoft 365), Webhooks und die tägliche Zusammenfassung werden in #012 umgesetzt. rahmen="primary"
werkzeuge={
emailKanal ? (
<div className="d-flex gap-2">
<form action={testSenden}>
<input type="hidden" name="id" value={emailKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-primary">
Test senden
</button>
</form>
<form action={kanalLoeschen}>
<input type="hidden" name="id" value={emailKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Entfernen
</button>
</form>
</div>
) : null
}
>
<p className="text-body-secondary">
Für Microsoft 365: Server <span className="pl-mono">smtp.office365.com</span>, Port 587,
Benutzer und Absender identisch mit dem Postfach. Das Passwort wird in der Datenbank gespeichert
und nie wieder angezeigt.
</p> </p>
<EmailFormular werte={emailWerte} />
</Card>
<Card
titel="Webhook"
rahmen="info"
werkzeuge={
webhookKanal ? (
<div className="d-flex gap-2">
<form action={testSenden}>
<input type="hidden" name="id" value={webhookKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-primary">
Test senden
</button>
</form>
<form action={kanalLoeschen}>
<input type="hidden" name="id" value={webhookKanal.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Entfernen
</button>
</form>
</div>
) : null
}
>
<p className="text-body-secondary">
Verschickt JSON mit Betreff, Text und strukturierten Feldern – damit lassen sich Matrix, Zabbix
oder Home Assistant anbinden.
</p>
<WebhookFormular werte={webhookWerte} />
</Card>
<Card titel="Zusammenfassung" rahmen="info">
<form action={zusammenfassungSpeichern} className="row g-2 align-items-end">
<div className="col-md-4">
<label className="form-label" htmlFor="zeitraum">
Regelmäßige Übersicht
</label>
<select id="zeitraum" name="zeitraum" className="form-select" defaultValue={zeitraum}>
<option value="aus">aus</option>
<option value="taeglich">täglich um 07:00 Uhr</option>
<option value="woechentlich">wöchentlich, montags um 07:00 Uhr</option>
</select>
</div>
<div className="col-md-3">
<button type="submit" className="btn btn-primary w-100">
Speichern
</button>
</div>
</form>
<form action={zusammenfassungJetztSenden} className="mt-3">
<input
type="hidden"
name="zeitraum"
value={zeitraum === "woechentlich" ? "woechentlich" : "taeglich"}
/>
<button type="submit" className="btn btn-outline-secondary btn-sm">
Zusammenfassung jetzt senden
</button>
</form>
</Card>
<Card titel="Versandprotokoll" rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Weg</th>
<th scope="col">Empfänger</th>
<th scope="col">Betreff</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{protokoll.map((eintrag) => (
<tr key={eintrag.id}>
<td className="small">{formatDatumZeit(eintrag.erstelltAm)}</td>
<td>{eintrag.kanal === "EMAIL" ? "E-Mail" : "Webhook"}</td>
<td className="small">{eintrag.empfaenger}</td>
<td className="small">{eintrag.betreff ?? "–"}</td>
<td>
{eintrag.status === "GESENDET" ? (
<Badge farbe="success">Gesendet</Badge>
) : eintrag.status === "FEHLGESCHLAGEN" ? (
<>
<Badge farbe="danger">Fehlgeschlagen</Badge>
<div className="small text-body-secondary">{eintrag.fehler}</div>
</>
) : (
<Badge farbe="neutral">Wartend</Badge>
)}
</td>
</tr>
))}
{protokoll.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
Noch nichts versendet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card> </Card>
</div> </div>
</div> </div>
@@ -0,0 +1,57 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { WarnregelFormular, type RegelAnzeige } from "@/components/alerts/WarnregelFormular";
import { Card } from "@/components/ui/Card";
import { prisma } from "@/lib/db";
import { warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { REGELN } from "@/server/alerts/regeln";
export const metadata = { title: "Warnregeln" };
const BESCHRIFTUNGEN: Record<string, string> = {
tage: "Tage (mehrere Stufen mit Komma)",
stunden: "Stunden",
};
export default async function WarnregelnSeite() {
await warnregelnSicherstellen();
const gespeichert = await prisma.warnregelEinstellung.findMany();
const nachName = new Map(gespeichert.map((eintrag) => [eintrag.regel, eintrag]));
const regeln: RegelAnzeige[] = REGELN.map((regel) => {
const eintrag = nachName.get(regel.name);
const werte = (eintrag?.schwellwerte ?? regel.standardSchwellwerte) as Record<string, unknown>;
return {
name: regel.name,
beschreibung: regel.beschreibung,
aktiv: eintrag?.aktiv ?? true,
schwellwerte: Object.keys(regel.standardSchwellwerte).map((schluessel) => ({
name: schluessel,
beschriftung: BESCHRIFTUNGEN[schluessel] ?? schluessel,
wert: Array.isArray(werte[schluessel])
? (werte[schluessel] as number[]).join(", ")
: String(werte[schluessel] ?? ""),
})),
};
});
return (
<>
<ContentHeader titel="Warnregeln" brotkrumen={[{ titel: "Einstellungen" }, { titel: "Warnregeln" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel="Regeln und Schwellwerte" rahmen="primary">
<p className="text-body-secondary">
Die Regeln laufen stündlich im Worker und zusätzlich nach jedem eingehenden Report. Eine Warnung
wird nur einmal geöffnet und schließt sich selbst, sobald die Ursache behoben ist. Offene
Warnungen stehen unter <Link href="/warnungen">Warnungen</Link>.
</p>
<WarnregelFormular regeln={regeln} />
</Card>
</div>
</div>
</>
);
}
+40 -2
View File
@@ -1,7 +1,11 @@
import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige"; import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { csvAntwort, csvDatei } from "@/lib/csv"; import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { hostListe, type HostFilter } from "@/server/queries/hostliste"; import { hostListe, type HostFilter } from "@/server/queries/hostliste";
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/** CSV-Export der Host-Tabelle; übernimmt die Filter aus der Adresse. */ /** CSV-Export der Host-Tabelle; übernimmt die Filter aus der Adresse. */
@@ -20,13 +24,48 @@ export async function GET(request: Request) {
}; };
const { gesamt } = await hostListe({ ...filter }, new Date()); const { gesamt } = await hostListe({ ...filter }, new Date());
const alle = []; const alle: ExportHost[] = [];
for (let seite = 1; alle.length < gesamt; seite++) { for (let seite = 1; alle.length < gesamt; seite++) {
const teil = await hostListe({ ...filter, seite }, new Date()); const teil = await hostListe({ ...filter, seite }, new Date());
if (teil.hosts.length === 0) break; if (teil.hosts.length === 0) break;
alle.push(...teil.hosts); alle.push(...teil.hosts);
} }
const datum = new Date().toISOString().slice(0, 10);
if (new URL(request.url).searchParams.get("format") === "pdf") {
return pdfAntwort(
Bericht({
titel: "Host-Liste",
untertitel: `${alle.length} Hosts`,
quer: true,
children: Tabelle({
spalten: [
{ titel: "Host", breite: 18, wert: (h: ExportHost) => h.hostname },
{ titel: "Kunde", breite: 18, wert: (h) => h.kunde?.name ?? "Nicht zugeordnet" },
{ titel: "Produkt", breite: 15, wert: (h) => PRODUKT_TEXT[h.produkt] },
{ titel: "Status", breite: 12, wert: (h) => STATUS_TEXT[statusAusDatenbank(h.subStatus)] },
{ titel: "Key", breite: 15, wert: (h) => h.subKey ?? "–" },
{
titel: "Sockets",
breite: 8,
wert: (h) => `${h.subLizenzierteSockets ?? "–"} / ${h.sockets ?? "–"}`,
rechts: true,
},
{
titel: "Ablauf",
breite: 14,
wert: (h) => (h.subNextduedate ? formatDatum(h.subNextduedate) : "–"),
rechts: true,
},
],
zeilen: alle,
}),
}),
`proxledger-hosts-${datum}.pdf`,
);
}
const inhalt = csvDatei( const inhalt = csvDatei(
[ [
"Hostname", "Hostname",
@@ -64,6 +103,5 @@ export async function GET(request: Request) {
]), ]),
); );
const datum = new Date().toISOString().slice(0, 10);
return csvAntwort(inhalt, `proxledger-hosts-${datum}.csv`); return csvAntwort(inhalt, `proxledger-hosts-${datum}.csv`);
} }
+147 -46
View File
@@ -1,26 +1,31 @@
import Link from "next/link"; import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader"; import { ContentHeader } from "@/components/layout/ContentHeader";
import { AblaufBadge, EinkaufBadge, VerkaufBadge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { formatDatum, tageBis } from "@/lib/format"; import { CopyableCode } from "@/components/ui/CopyableCode";
import { HEUTE, hosts, kunden } from "@/mock/data"; import { formatDatum, formatTageBis } from "@/lib/format";
import { ablaufDaten } from "@/server/queries/jahresbahn";
export const metadata = { title: "Jahresbahn" }; export const metadata = { title: "Jahresbahn" };
const MONATE = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; const MONATE = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
const GESAMT_TAGE = 365;
export default function JahresbahnSeite() { export default async function JahresbahnSeite() {
const start = new Date(Date.UTC(HEUTE.getUTCFullYear(), HEUTE.getUTCMonth(), 1)); const jetzt = new Date();
const gesamtTage = 365; const { gruppen, naechste90Tage, abgelaufen } = await ablaufDaten(jetzt);
function position(datum: string): number | null { // Die Bahn beginnt am Ersten des laufenden Monats und zeigt zwölf Monate.
const tage = Math.round((new Date(datum).getTime() - start.getTime()) / 86_400_000); const start = new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), 1));
if (tage < 0 || tage > gesamtTage) return null;
return (tage / gesamtTage) * 100; function position(datum: Date): number | null {
const tage = Math.round((datum.getTime() - start.getTime()) / 86_400_000);
if (tage < 0 || tage > GESAMT_TAGE) return null;
return (tage / GESAMT_TAGE) * 100;
} }
const heutePosition = position(HEUTE.toISOString().slice(0, 10)) ?? 0; const heutePosition = position(jetzt) ?? 0;
const monate = Array.from({ length: 12 }, (_, i) => {
const monatsBeschriftung = Array.from({ length: 12 }, (_, i) => {
const datum = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + i, 1)); const datum = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + i, 1));
return `${MONATE[datum.getUTCMonth()]} ${String(datum.getUTCFullYear()).slice(2)}`; return `${MONATE[datum.getUTCMonth()]} ${String(datum.getUTCFullYear()).slice(2)}`;
}); });
@@ -31,79 +36,175 @@ export default function JahresbahnSeite() {
<div className="app-content"> <div className="app-content">
<div className="container-fluid"> <div className="container-fluid">
<Card titel="Abläufe der nächsten 12 Monate" rahmen="primary"> <Card titel="Abläufe der nächsten zwölf Monate" rahmen="primary">
{gruppen.length === 0 ? (
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
) : (
<div className="table-responsive"> <div className="table-responsive">
<div style={{ minWidth: "760px" }}> <div style={{ minWidth: "820px" }}>
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "12rem" }}> <div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
{monatsBeschriftung.map((monat) => ( {monate.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}> <div key={monat} style={{ width: `${100 / 12}%` }}>
{monat} {monat}
</div> </div>
))} ))}
</div> </div>
{kunden.map((kunde) => { {gruppen.map((gruppe) => (
const eigene = hosts.filter( <div key={gruppe.kundeId ?? "ohne"} className="mb-3">
(h) => h.kundeId === kunde.id && h.subscription.nextduedate !== null,
);
if (eigene.length === 0) return null;
return (
<div key={kunde.id} className="mb-3">
<div className="fw-semibold small mb-1"> <div className="fw-semibold small mb-1">
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none"> {gruppe.kundeId ? (
{kunde.name} <Link href={`/kunden/${gruppe.kundeId}`} className="text-decoration-none">
{gruppe.name}
</Link> </Link>
) : (
<span className="badge pl-bg-neutral">{gruppe.name}</span>
)}
</div> </div>
{eigene.map((host) => {
const datum = host.subscription.nextduedate!; {gruppe.eintraege.map((eintrag) => {
const links = position(datum.slice(0, 10)); const links = position(eintrag.ablauf);
const tage = tageBis(datum, HEUTE);
const farbe = const farbe =
tage === null || tage < 14 eintrag.tage < 14
? "var(--pl-danger)" ? "var(--pl-danger)"
: tage < 30 : eintrag.tage < 30
? "var(--pl-warning)" ? "var(--pl-warning)"
: "var(--pl-info)"; : "var(--pl-info)";
return ( return (
<div key={host.id} className="d-flex align-items-center mb-1"> <div key={eintrag.hostId} className="d-flex align-items-center mb-1">
<div className="small text-truncate" style={{ width: "12rem" }}> <div className="small text-truncate" style={{ width: "13rem" }}>
<Link href={`/hosts/${host.id}`} className="text-decoration-none"> <Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{host.hostname} {eintrag.hostname}
</Link> </Link>
</div> </div>
<div className="pl-timeline-row flex-grow-1 position-relative"> <div className="pl-timeline-row flex-grow-1 position-relative">
<div className="pl-timeline-heute" style={{ left: `${heutePosition}%` }} /> <div className="pl-timeline-heute" style={{ left: `${heutePosition}%` }} />
{links === null ? ( {links === null ? (
<span className="small text-body-secondary ms-2"> <span className="small text-body-secondary ms-2">
außerhalb des Zeitraums ({formatDatum(datum)}) außerhalb des Zeitraums ({formatDatum(eintrag.ablauf)})
</span> </span>
) : ( ) : (
<div <Link
className="pl-timeline-bar" href={
eintrag.laufzeitId
? `/rechnungen/${eintrag.laufzeitId}`
: `/hosts/${eintrag.hostId}`
}
className="pl-timeline-bar text-decoration-none"
style={{ style={{
left: `${Math.max(0, links - 6)}%`, left: `${Math.min(92, Math.max(0, links - 5))}%`,
backgroundColor: farbe, backgroundColor: farbe,
color: farbe === "var(--pl-warning)" ? "var(--pl-warning-text)" : "#fff", color: farbe === "var(--pl-warning)" ? "var(--pl-warning-text)" : "#fff",
}} }}
title={`${host.hostname} läuft am ${formatDatum(datum)} ab`} title={`${eintrag.hostname} läuft am ${formatDatum(eintrag.ablauf)} ab`}
> >
{formatDatum(datum)} {formatDatum(eintrag.ablauf)}
</div> </Link>
)} )}
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
); ))}
})}
</div> </div>
</div> </div>
)}
<p className="small text-body-secondary mb-0"> <p className="small text-body-secondary mb-0">
Die rote Linie markiert heute ({formatDatum(HEUTE)}). Farbe der Balken: rot unter 14 Tagen, gelb Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
unter 30 Tagen, sonst blau. unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
</p> </p>
</Card> </Card>
<div className="row">
<div className="col-lg-8">
<Card
titel={`Ablauf-Kalender: nächste 90 Tage (${naechste90Tage.length})`}
rahmen="warning"
tabelle
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Ablauf</th>
<th scope="col">Host</th>
<th scope="col">Kunde</th>
<th scope="col">Key</th>
<th scope="col">Proxmox</th>
<th scope="col">Kundenrechnung</th>
</tr>
</thead>
<tbody>
{naechste90Tage.map((eintrag) => (
<tr key={eintrag.hostId}>
<td>
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname}
</Link>
</td>
<td>
{eintrag.kunde ? (
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<CopyableCode wert={eintrag.key} />
</td>
<td>
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
</td>
<td>
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
</td>
</tr>
))}
{naechste90Tage.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
In den nächsten 90 Tagen läuft nichts ab.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
<div className="col-lg-4">
<Card
titel={`Bereits abgelaufen (${abgelaufen.length})`}
rahmen={abgelaufen.length > 0 ? "danger" : "info"}
>
{abgelaufen.length === 0 ? (
<p className="mb-0 text-body-secondary">Keine abgelaufenen Subscriptions.</p>
) : (
<ul className="list-unstyled mb-0">
{abgelaufen.map((eintrag) => (
<li key={eintrag.hostId} className="mb-2">
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
{eintrag.hostname}
</Link>
<div className="small text-body-secondary">
{eintrag.kunde?.name ?? "Nicht zugeordnet"} · {formatDatum(eintrag.ablauf)} (
{formatTageBis(eintrag.tage)})
</div>
</li>
))}
</ul>
)}
</Card>
</div>
</div>
</div> </div>
</div> </div>
</> </>
@@ -0,0 +1,74 @@
import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
export const dynamic = "force-dynamic";
/** Kundenunterlage als PDF: Hosts, Laufzeiten und Summen eines Kunden. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const kunde = await kundeMitDetails(id);
if (!kunde) return new Response("Nicht gefunden", { status: 404 });
const laufzeiten = await laufzeitenVonKunde(kunde.id);
const gesamt = summen(laufzeiten);
const proJahr = summenProJahr(laufzeiten);
const datum = new Date().toISOString().slice(0, 10);
return pdfAntwort(
Bericht({
titel: kunde.name,
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
children: [
Abschnitt({ titel: `Hosts (${kunde.hosts.length})` }),
Tabelle({
spalten: [
{ titel: "Host", breite: 28, wert: (h: (typeof kunde.hosts)[number]) => h.hostname },
{ titel: "Produkt", breite: 22, wert: (h) => PRODUKT_TEXT[h.produkt] },
{ titel: "Status", breite: 16, wert: (h) => STATUS_TEXT[statusAusDatenbank(h.subStatus)] },
{ titel: "Key", breite: 20, wert: (h) => h.subKey ?? "–" },
{
titel: "Ablauf",
breite: 14,
wert: (h) => (h.subNextduedate ? formatDatum(h.subNextduedate) : "–"),
rechts: true,
},
],
zeilen: kunde.hosts,
}),
Abschnitt({ titel: `Laufzeiten (${laufzeiten.length})` }),
Tabelle({
spalten: [
{
titel: "Zeitraum",
breite: 26,
wert: (l: (typeof laufzeiten)[number]) => `${formatDatum(l.start)} – ${formatDatum(l.ende)}`,
},
{ titel: "Host", breite: 18, wert: (l) => l.hostname },
{ titel: "Proxmox-Rechnung", breite: 18, wert: (l) => l.einkauf?.nummer ?? "–" },
{ titel: "Einkauf", breite: 12, wert: (l) => formatEuro(l.einkauf?.nettoBetrag), rechts: true },
{ titel: "Kundenrechnung", breite: 18, wert: (l) => l.verkauf?.nummer ?? "–" },
{ titel: "Verkauf", breite: 8, wert: (l) => formatEuro(l.verkauf?.nettoBetrag), rechts: true },
],
zeilen: laufzeiten,
summe: ["Summe", "", "", formatEuro(gesamt.einkauf), "", formatEuro(gesamt.verkauf)],
}),
Abschnitt({ titel: "Summen pro Jahr" }),
Tabelle({
spalten: [
{ titel: "Jahr", breite: 25, wert: (j: (typeof proJahr)[number]) => j.jahr },
{ titel: "Einkauf netto", breite: 25, wert: (j) => formatEuro(j.einkauf), rechts: true },
{ titel: "Verkauf netto", breite: 25, wert: (j) => formatEuro(j.verkauf), rechts: true },
{ titel: "Marge", breite: 25, wert: (j) => formatEuro(j.marge), rechts: true },
],
zeilen: proJahr,
summe: ["Gesamt", formatEuro(gesamt.einkauf), formatEuro(gesamt.verkauf), formatEuro(gesamt.marge)],
}),
],
}),
`proxledger-${kunde.kundennummer}-${datum}.pdf`,
);
}
+6
View File
@@ -41,10 +41,16 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
titel={kunde.name} titel={kunde.name}
brotkrumen={[{ titel: "Kunden", href: "/kunden" }, { titel: kunde.name }]} brotkrumen={[{ titel: "Kunden", href: "/kunden" }, { titel: kunde.name }]}
aktion={ aktion={
<span className="d-flex gap-2">
<a href={`/kunden/${kunde.id}/export`} className="btn btn-sm btn-outline-secondary">
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
Unterlage
</a>
<Link href={`/kunden/${kunde.id}/bearbeiten`} className="btn btn-sm btn-outline-primary"> <Link href={`/kunden/${kunde.id}/bearbeiten`} className="btn btn-sm btn-outline-primary">
<i className="bi bi-pencil me-1" aria-hidden="true" /> <i className="bi bi-pencil me-1" aria-hidden="true" />
Bearbeiten Bearbeiten
</Link> </Link>
</span>
} }
/> />
+107
View File
@@ -0,0 +1,107 @@
import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import {
kundenListe,
offeneRechnungspunkteProKunde,
laufzeitenVonKunde,
summen,
} from "@/server/queries/kunden";
export const dynamic = "force-dynamic";
/** Kundenübersicht als CSV oder PDF. */
export async function GET(request: Request) {
const format = new URL(request.url).searchParams.get("format") ?? "csv";
const [kunden, punkte] = await Promise.all([kundenListe(), offeneRechnungspunkteProKunde()]);
const zeilen = await Promise.all(
kunden.map(async (kunde) => {
const laufzeiten = await laufzeitenVonKunde(kunde.id);
const betraege = summen(laufzeiten);
return {
...kunde,
offeneRechnungspunkte: punkte.get(kunde.id)?.offen ?? 0,
...betraege,
};
}),
);
const datum = new Date().toISOString().slice(0, 10);
if (format === "pdf") {
return pdfAntwort(
Bericht({
titel: "Kundenübersicht",
untertitel: `${zeilen.length} Kunden`,
quer: true,
children: Tabelle({
spalten: [
{ titel: "Kunde", breite: 26, wert: (z: (typeof zeilen)[number]) => z.name },
{ titel: "Nummer", breite: 10, wert: (z) => z.kundennummer },
{ titel: "Hosts", breite: 7, wert: (z) => String(z.hosts), rechts: true },
{ titel: "Aktiv", breite: 7, wert: (z) => String(z.aktiv), rechts: true },
{
titel: "Nächster Ablauf",
breite: 13,
wert: (z) => (z.naechsterAblauf ? formatDatum(z.naechsterAblauf) : "–"),
},
{ titel: "Warnungen", breite: 9, wert: (z) => String(z.offeneAlerts), rechts: true },
{
titel: "Offene Rechn.",
breite: 10,
wert: (z) => String(z.offeneRechnungspunkte),
rechts: true,
},
{ titel: "Einkauf", breite: 9, wert: (z) => formatEuro(z.einkauf), rechts: true },
{ titel: "Marge", breite: 9, wert: (z) => formatEuro(z.marge), rechts: true },
],
zeilen,
summe: [
"Summe",
"",
String(zeilen.reduce((s, z) => s + z.hosts, 0)),
String(zeilen.reduce((s, z) => s + z.aktiv, 0)),
"",
"",
"",
formatEuro(zeilen.reduce((s, z) => s + z.einkauf, 0)),
formatEuro(zeilen.reduce((s, z) => s + z.marge, 0)),
],
}),
}),
`proxledger-kunden-${datum}.pdf`,
);
}
const inhalt = csvDatei(
[
"Kunde",
"Kundennummer",
"Ansprechpartner",
"Hosts",
"Aktiv lizenziert",
"Nächster Ablauf",
"Offene Warnungen",
"Offene Rechnungspunkte",
"Einkauf netto",
"Verkauf netto",
"Marge netto",
],
zeilen.map((z) => [
z.name,
z.kundennummer,
z.ansprechpartner,
z.hosts,
z.aktiv,
z.naechsterAblauf,
z.offeneAlerts,
z.offeneRechnungspunkte,
z.einkauf,
z.verkauf,
z.marge,
]),
);
return csvAntwort(inhalt, `proxledger-kunden-${datum}.csv`);
}
+11
View File
@@ -21,10 +21,21 @@ export default async function KundenSeite() {
rahmen="primary" rahmen="primary"
tabelle tabelle
werkzeuge={ werkzeuge={
<>
{/* Downloads: bewusst kein <Link>, sonst würde Next.js die Datei vorab laden. */}
<a href="/kunden/export" download className="btn btn-sm btn-outline-secondary">
<i className="bi bi-filetype-csv me-1" aria-hidden="true" />
CSV
</a>
<a href="/kunden/export?format=pdf" download className="btn btn-sm btn-outline-secondary">
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
PDF
</a>
<Link href="/kunden/neu" className="btn btn-sm btn-primary"> <Link href="/kunden/neu" className="btn btn-sm btn-primary">
<i className="bi bi-plus-lg me-1" aria-hidden="true" /> <i className="bi bi-plus-lg me-1" aria-hidden="true" />
Kunde anlegen Kunde anlegen
</Link> </Link>
</>
} }
> >
<div className="table-responsive"> <div className="table-responsive">
+1
View File
@@ -30,6 +30,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<Sidebar <Sidebar
hostsAnzahl={zahlen.hostsGesamt} hostsAnzahl={zahlen.hostsGesamt}
offeneRechnungspunkte={zahlen.offeneRechnungspunkte} offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
offeneWarnungen={zahlen.offeneAlerts}
benutzer={BENUTZER} benutzer={BENUTZER}
/> />
<main className="app-main">{children}</main> <main className="app-main">{children}</main>
+1
View File
@@ -39,6 +39,7 @@ export default async function DashboardSeite() {
dringlichkeit: aufgabe.dringlichkeit, dringlichkeit: aufgabe.dringlichkeit,
badgeText: aufgabe.badgeText, badgeText: aufgabe.badgeText,
href: aufgabe.href, href: aufgabe.href,
aktion: aufgabe.aktion,
})), })),
...ohneKunde.map((host) => ({ ...ohneKunde.map((host) => ({
id: `zuweisen-${host.id}`, id: `zuweisen-${host.id}`,
@@ -0,0 +1,197 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { KundenrechnungFormular, ProxmoxRechnungFormular } from "@/components/rechnungen/RechnungsFormulare";
import { Badge, EinkaufBadge, VerkaufBadge } from "@/components/ui/Badges";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { laufzeitDetail } from "@/server/queries/rechnungen";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const laufzeit = await laufzeitDetail(id);
return { title: laufzeit ? `Laufzeit ${laufzeit.host.hostname}` : "Laufzeit" };
}
export default async function LaufzeitSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const laufzeit = await laufzeitDetail(id);
if (!laufzeit) notFound();
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return (
<>
<ContentHeader
titel={`Laufzeit ${zeitraum}`}
brotkrumen={[
{ titel: "Rechnungen", href: "/rechnungen" },
{ titel: laufzeit.host.hostname, href: `/hosts/${laufzeit.host.id}` },
{ titel: zeitraum },
]}
aktion={laufzeit.status === "VORSCHLAG" ? <Badge farbe="info">Vorschlag</Badge> : null}
/>
<div className="app-content">
<div className="container-fluid">
{laufzeit.status === "VORSCHLAG" ? (
<Callout
titel="Vom Agent vorgeschlagen"
farbe="info"
aktion={
<div className="d-flex gap-2">
<form action={laufzeitBestaetigenAktion}>
<input type="hidden" name="laufzeitId" value={laufzeit.id} />
<input type="hidden" name="hostId" value={laufzeit.host.id} />
<button type="submit" className="btn btn-primary pl-touch-target">
Bestätigen
</button>
</form>
<form action={laufzeitVerwerfenAktion}>
<input type="hidden" name="laufzeitId" value={laufzeit.id} />
<input type="hidden" name="hostId" value={laufzeit.host.id} />
<button type="submit" className="btn btn-outline-danger pl-touch-target">
Verwerfen
</button>
</form>
</div>
}
>
Der Host hat ein neues Ablaufdatum gemeldet. Mit dem Erfassen einer Proxmox-Rechnung gilt die
Laufzeit automatisch als bestätigt.
</Callout>
) : null}
<div className="row">
<div className="col-lg-4">
<Card titel="Laufzeit" rahmen="primary">
<dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Host</dt>
<dd className="col-7">
<Link href={`/hosts/${laufzeit.host.id}`}>{laufzeit.host.hostname}</Link>
</dd>
<dt className="col-5 fw-normal text-body-secondary">Kunde</dt>
<dd className="col-7">
{laufzeit.host.kunde ? (
<Link href={`/kunden/${laufzeit.host.kunde.id}`}>{laufzeit.host.kunde.name}</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</dd>
<dt className="col-5 fw-normal text-body-secondary">Produkt</dt>
<dd className="col-7">{PRODUKT_TEXT[laufzeit.host.produkt]}</dd>
<dt className="col-5 fw-normal text-body-secondary">Key</dt>
<dd className="col-7">
<CopyableCode wert={laufzeit.key} />
</dd>
<dt className="col-5 fw-normal text-body-secondary">Beginn</dt>
<dd className="col-7">{formatDatum(laufzeit.start)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Ende</dt>
<dd className="col-7">{formatDatum(laufzeit.ende)}</dd>
</dl>
<hr />
<dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
<dd className={`col-7 ${(marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}>
{marge === null ? "–" : formatEuro(marge)}
</dd>
</dl>
</Card>
{einkauf && einkauf.positionen.length > 1 ? (
<Card titel="Sammelrechnung" rahmen="info">
<p className="small">
Die Proxmox-Rechnung <span className="pl-mono">{einkauf.nummer}</span> deckt mehrere
Laufzeiten ab:
</p>
<ul className="list-unstyled mb-0 small">
{einkauf.positionen.map((position) => (
<li key={position.id} className="mb-1">
{position.laufzeit ? (
<Link href={`/rechnungen/${position.laufzeit.id}`}>
{position.laufzeit.host.hostname}
</Link>
) : (
position.bezeichnung
)}{" "}
· {formatEuro(Number(position.nettoBetrag))}
</li>
))}
</ul>
</Card>
) : null}
</div>
<div className="col-lg-8">
<Card
titel="Proxmox-Rechnung (Einkauf)"
rahmen={einkauf?.bezahlt ? "info" : "danger"}
werkzeuge={<EinkaufBadge bezahlt={einkauf ? einkauf.bezahlt : null} />}
>
<ProxmoxRechnungFormular
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
werte={{
nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
nettoBetrag: einkaufBetrag,
ustSatz: Number(einkauf?.ustSatz ?? 19),
rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined
? null
: Number(einkauf.rabatt),
zahlungsart: einkauf?.zahlungsart ?? null,
bezahlt: einkauf?.bezahlt ?? false,
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
anhangId: einkauf?.anhangId ?? null,
anhangName: einkauf?.anhang?.dateiname ?? null,
}}
/>
</Card>
<Card
titel="Kundenrechnung (Verkauf)"
rahmen={verkauf?.erstellt ? "info" : "warning"}
werkzeuge={
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
}
>
<KundenrechnungFormular
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
kundeVorhanden={Boolean(laufzeit.host.kunde)}
werte={{
erstellt: verkauf?.erstellt ?? false,
nummer: verkauf?.nummer ?? null,
datum: verkauf?.datum?.toISOString() ?? null,
nettoBetrag: verkaufBetrag,
ustSatz: Number(verkauf?.ustSatz ?? 19),
bezahlt: verkauf?.bezahlt ?? false,
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
anhangId: verkauf?.anhangId ?? null,
anhangName: verkauf?.anhang?.dateiname ?? null,
}}
/>
</Card>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,108 @@
import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
export const dynamic = "force-dynamic";
/** Rechnungsliste als CSV (Buchhaltung) oder PDF, mit den Filtern der Ansicht. */
export async function GET(request: Request) {
const parameter = new URL(request.url).searchParams;
const format = parameter.get("format") ?? "csv";
const filter: RechnungsFilter = {
status: (parameter.get("status") as RechnungsFilter["status"]) ?? "alle",
kunde: parameter.get("kunde") ?? undefined,
jahr: parameter.get("jahr") ?? undefined,
q: parameter.get("q") ?? undefined,
};
const { zeilen, summen } = await rechnungsListe(filter);
const datum = new Date().toISOString().slice(0, 10);
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
if (format === "pdf") {
return pdfAntwort(
Bericht({
titel: "Rechnungsliste",
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
quer: true,
children: Tabelle({
spalten: [
{
titel: "Kunde",
breite: 18,
wert: (z: (typeof zeilen)[number]) => z.kunde?.name ?? "Nicht zugeordnet",
},
{ titel: "Host", breite: 13, wert: (z) => z.hostname },
{
titel: "Laufzeit",
breite: 16,
wert: (z) => `${formatDatum(z.start)} – ${formatDatum(z.ende)}`,
},
{ titel: "Proxmox-Rechnung", breite: 13, wert: (z) => z.einkaufNummer ?? "–" },
{ titel: "Einkauf", breite: 9, wert: (z) => formatEuro(z.einkaufBetrag), rechts: true },
{ titel: "Bezahlt", breite: 7, wert: (z) => (z.einkaufBezahlt ? "ja" : "nein") },
{ titel: "Kundenrechnung", breite: 12, wert: (z) => z.verkaufNummer ?? "–" },
{ titel: "Verkauf", breite: 6, wert: (z) => formatEuro(z.verkaufBetrag), rechts: true },
{
titel: "Marge",
breite: 6,
wert: (z) => (z.marge === null ? "–" : formatEuro(z.marge)),
rechts: true,
},
],
zeilen,
summe: [
"Summe",
"",
"",
"",
formatEuro(summen.einkauf),
"",
"",
formatEuro(summen.verkauf),
formatEuro(summen.marge),
],
}),
}),
`proxledger-rechnungen-${filter.jahr ?? "alle"}-${datum}.pdf`,
);
}
const inhalt = csvDatei(
[
"Kunde",
"Host",
"Key",
"Laufzeit von",
"Laufzeit bis",
"Proxmox-Rechnung",
"Einkauf netto",
"Proxmox bezahlt",
"Fällig am",
"Kundenrechnung",
"Verkauf netto",
"Kundenrechnung erstellt",
"Vom Kunden bezahlt",
"Marge netto",
],
zeilen.map((z) => [
z.kunde?.name ?? "Nicht zugeordnet",
z.hostname,
z.key,
z.start,
z.ende,
z.einkaufNummer,
z.einkaufBetrag,
z.einkaufBezahlt === null ? "" : z.einkaufBezahlt ? "ja" : "nein",
z.einkaufFaelligAm,
z.verkaufNummer,
z.verkaufBetrag,
z.verkaufErstellt ? "ja" : "nein",
z.verkaufBezahlt ? "ja" : "nein",
z.marge,
]),
);
return csvAntwort(inhalt, `proxledger-rechnungen-${filter.jahr ?? "alle"}-${datum}.csv`);
}
+227 -51
View File
@@ -1,26 +1,55 @@
import Link from "next/link"; import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader"; import { ContentHeader } from "@/components/layout/ContentHeader";
import { StatusHaken } from "@/components/rechnungen/StatusHaken";
import { Badge } from "@/components/ui/Badges";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode"; import { CopyableCode } from "@/components/ui/CopyableCode";
import { formatDatum, formatEuro } from "@/lib/format"; import { InfoBox } from "@/components/ui/SmallBox";
import { hosts, laufzeiten } from "@/mock/data"; import { prisma } from "@/lib/db";
import { getKunde, istAbgeschlossen } from "@/mock/queries"; import { formatDatum, formatEuro, tageBis } from "@/lib/format";
import {
anzahlVorschlaege,
laufzeitJahre,
rechnungsListe,
type RechnungsFilter,
} from "@/server/queries/rechnungen";
export const metadata = { title: "Rechnungen" }; export const metadata = { title: "Rechnungen" };
export default async function RechnungenSeite({ type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
searchParams,
}: { function einzeln(wert: string | string[] | undefined): string | undefined {
searchParams: Promise<{ filter?: string }>; const text = Array.isArray(wert) ? wert[0] : wert;
}) { return text && text.length > 0 ? text : undefined;
const { filter = "offen" } = await searchParams; }
const alle = laufzeiten.filter((l) => !l.vorschlag);
const liste = export default async function RechnungenSeite({ searchParams }: { searchParams: SuchParameter }) {
filter === "erledigt" const roh = await searchParams;
? alle.filter(istAbgeschlossen) const filter: RechnungsFilter = {
: filter === "alle" status: (einzeln(roh.status) as RechnungsFilter["status"]) ?? "offen",
? alle kunde: einzeln(roh.kunde),
: alle.filter((l) => !istAbgeschlossen(l)); jahr: einzeln(roh.jahr),
q: einzeln(roh.q),
};
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([
rechnungsListe(filter),
laufzeitJahre(),
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
anzahlVorschlaege(),
]);
const heute = new Date();
function adresse(aenderung: Record<string, string | undefined>) {
const params = new URLSearchParams();
for (const [schluessel, wert] of Object.entries({ ...filter, ...aenderung })) {
if (wert) params.set(schluessel, String(wert));
}
const text = params.toString();
return text ? `/rechnungen?${text}` : "/rechnungen";
}
return ( return (
<> <>
@@ -28,28 +57,145 @@ export default async function RechnungenSeite({
<div className="app-content"> <div className="app-content">
<div className="container-fluid"> <div className="container-fluid">
{vorschlaege > 0 && filter.status !== "vorschlaege" ? (
<Callout
titel={`${vorschlaege} vorgeschlagene Laufzeit${vorschlaege === 1 ? "" : "en"}`}
farbe="info"
aktion={
<Link href={adresse({ status: "vorschlaege" })} className="btn btn-primary pl-touch-target">
Ansehen
</Link>
}
>
Die Agents haben neue Ablaufdaten gemeldet. Bestätige die Laufzeit und trage die Rechnungsdaten
ein.
</Callout>
) : null}
<p className="small text-body-secondary mb-2">
Summen beziehen sich auf die aktuelle Auswahl ({zeilen.length} Laufzeiten).
</p>
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox
text="Einkauf netto"
wert={formatEuro(summen.einkauf)}
icon="bi-cart"
farbe="neutral"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Verkauf netto"
wert={formatEuro(summen.verkauf)}
icon="bi-receipt"
farbe="primary"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Marge"
wert={formatEuro(summen.marge)}
icon="bi-graph-up-arrow"
farbe="success"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox text="Laufzeiten" wert={String(zeilen.length)} icon="bi-calendar-range" farbe="info" />
</div>
</div>
<Card <Card
titel={`${liste.length} Laufzeiten`} titel="Laufzeiten und Rechnungen"
rahmen="primary" rahmen="primary"
tabelle tabelle
werkzeuge={ werkzeuge={
<>
<a
href={adresse({}).replace("/rechnungen", "/rechnungen/export")}
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-filetype-csv me-1" aria-hidden="true" />
CSV
</a>
<a
href={adresse({ format: "pdf" }).replace("/rechnungen", "/rechnungen/export")}
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-filetype-pdf me-1" aria-hidden="true" />
PDF
</a>
<div className="btn-group btn-group-sm" role="group" aria-label="Filter"> <div className="btn-group btn-group-sm" role="group" aria-label="Filter">
{[ {(
[
["offen", "Offen"], ["offen", "Offen"],
["erledigt", "Erledigt"], ["erledigt", "Erledigt"],
["alle", "Alle"], ["alle", "Alle"],
].map(([wert, text]) => ( ["vorschlaege", `Vorschläge${vorschlaege ? ` (${vorschlaege})` : ""}`],
] as const
).map(([wert, text]) => (
<Link <Link
key={wert} key={wert}
href={`/rechnungen?filter=${wert}`} href={adresse({ status: wert })}
className={`btn ${filter === wert ? "btn-primary" : "btn-outline-primary"}`} className={`btn ${filter.status === wert ? "btn-primary" : "btn-outline-primary"}`}
> >
{text} {text}
</Link> </Link>
))} ))}
</div> </div>
</>
} }
> >
<form className="row g-2 p-3 border-bottom" role="search">
<input type="hidden" name="status" value={filter.status} />
<div className="col-md-4">
<input
name="q"
type="search"
defaultValue={filter.q ?? ""}
className="form-control"
placeholder="Rechnungsnummer, Key oder Host …"
aria-label="Suche"
/>
</div>
<div className="col-6 col-md-3">
<select
name="kunde"
defaultValue={filter.kunde ?? ""}
className="form-select"
aria-label="Kunde"
>
<option value="">Alle Kunden</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</div>
<div className="col-6 col-md-2">
<select
name="jahr"
defaultValue={filter.jahr ?? ""}
className="form-select"
aria-label="Jahr"
>
<option value="">Alle Jahre</option>
{jahre.map((jahr) => (
<option key={jahr} value={jahr}>
{jahr}
</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100">
Filtern
</button>
</div>
</form>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle">
<thead> <thead>
@@ -60,76 +206,106 @@ export default async function RechnungenSeite({
<th scope="col">Proxmox-Rechnung</th> <th scope="col">Proxmox-Rechnung</th>
<th scope="col">Netto</th> <th scope="col">Netto</th>
<th scope="col" className="text-center"> <th scope="col" className="text-center">
Proxmox bezahlt Bezahlt
</th> </th>
<th scope="col">Kundenrechnung</th> <th scope="col">Kundenrechnung</th>
<th scope="col">Netto</th> <th scope="col">Netto</th>
<th scope="col" className="text-center"> <th scope="col" className="text-center">
Kundenrechnung erstellt Erstellt
</th>
<th scope="col" className="text-center">
Bezahlt
</th> </th>
<th scope="col">Marge</th> <th scope="col">Marge</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{liste.map((laufzeit) => { {zeilen.map((zeile) => {
const host = hosts.find((h) => h.id === laufzeit.hostId); const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
const kunde = getKunde(host?.kundeId ?? null);
const marge = (laufzeit.verkauf?.nettoBetrag ?? 0) - (laufzeit.einkauf?.nettoBetrag ?? 0);
return ( return (
<tr key={laufzeit.id}> <tr key={zeile.id}>
<td> <td>
{kunde ? ( {zeile.kunde ? (
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none"> <Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
{kunde.name} {zeile.kunde.name}
</Link> </Link>
) : ( ) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span> <span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)} )}
</td> </td>
<td> <td>
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none"> <Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
{host?.hostname} {zeile.hostname}
</Link> </Link>
</td> </td>
<td> <td>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)} <Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link>
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
</td> </td>
<td> <td>
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} /> <CopyableCode wert={zeile.einkaufNummer} />
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
<div className="small pl-text-danger">
fällig {formatDatum(zeile.einkaufFaelligAm)}
</div>
) : null}
</td> </td>
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td> <td>{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center"> <td className="text-center">
<input <StatusHaken
type="checkbox" laufzeitId={zeile.id}
className="form-check-input pl-touch-target" hostId={zeile.hostId}
defaultChecked={laufzeit.einkauf?.bezahlt ?? false} feld="einkaufBezahlt"
aria-label="Proxmox-Rechnung bezahlt" wert={zeile.einkaufBezahlt === true}
deaktiviert={zeile.einkaufBezahlt === null}
titel="Proxmox-Rechnung bezahlt"
/> />
</td> </td>
<td> <td>
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} /> <CopyableCode wert={zeile.verkaufNummer} />
</td> </td>
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td> <td>{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center"> <td className="text-center">
<input <StatusHaken
type="checkbox" laufzeitId={zeile.id}
className="form-check-input pl-touch-target" hostId={zeile.hostId}
defaultChecked={laufzeit.verkauf?.erstellt ?? false} feld="verkaufErstellt"
aria-label="Kundenrechnung erstellt" wert={zeile.verkaufErstellt}
titel="Kundenrechnung erstellt"
/> />
</td> </td>
<td className={marge >= 0 ? "pl-text-success" : "pl-text-danger"}> <td className="text-center">
{laufzeit.verkauf?.nettoBetrag ? formatEuro(marge) : "–"} <StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="verkaufBezahlt"
wert={zeile.verkaufBezahlt}
deaktiviert={!zeile.verkaufErstellt}
titel="Kundenrechnung vom Kunden bezahlt"
/>
</td>
<td className={(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
</td> </td>
</tr> </tr>
); );
})} })}
{zeilen.length === 0 ? (
<tr>
<td colSpan={11} className="text-center text-body-secondary py-4">
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
</td>
</tr>
) : null}
</tbody> </tbody>
</table> </table>
</div> </div>
</Card> </Card>
<p className="small text-body-secondary"> <p className="small text-body-secondary">
Im Prototyp sind die Häkchen noch ohne Wirkung. Ab #008 setzen sie den Status direkt. Die Häkchen wirken sofort. Für Rechnungsnummer, Beträge und PDF die Laufzeit öffnen.
</p> </p>
</div> </div>
</div> </div>
+200
View File
@@ -0,0 +1,200 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { PruefenKnopf } from "@/components/alerts/PruefenKnopf";
import { Badge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatumZeit } from "@/lib/format";
import { alertQuittieren, alertSchliessen } from "@/server/actions/alerts";
export const metadata = { title: "Warnungen" };
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
const SCHWERE_FARBE = { GEFAHR: "danger", WARNUNG: "warning", INFO: "info" } as const;
export default async function WarnungenSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const status = (Array.isArray(roh.status) ? roh.status[0] : roh.status) ?? "offen";
const [alerts, zahlen] = await Promise.all([
prisma.alert.findMany({
where: status === "erledigt" ? { erledigtAm: { not: null } } : { erledigtAm: null },
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
take: 200,
include: {
host: { select: { id: true, hostname: true } },
kunde: { select: { id: true, name: true } },
laufzeit: { select: { id: true } },
},
}),
prisma.alert.groupBy({
by: ["schwere"],
where: { erledigtAm: null },
_count: { _all: true },
}),
]);
const anzahl = (schwere: string) => zahlen.find((z) => z.schwere === schwere)?._count._all ?? 0;
return (
<>
<ContentHeader titel="Warnungen" brotkrumen={[{ titel: "Warnungen" }]} aktion={<PruefenKnopf />} />
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox
text="Gefahr"
wert={String(anzahl("GEFAHR"))}
icon="bi-exclamation-octagon"
farbe="danger"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Warnung"
wert={String(anzahl("WARNUNG"))}
icon="bi-exclamation-triangle"
farbe="warning"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox text="Hinweis" wert={String(anzahl("INFO"))} icon="bi-info-circle" farbe="info" />
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Offen gesamt"
wert={String(anzahl("GEFAHR") + anzahl("WARNUNG") + anzahl("INFO"))}
icon="bi-bell"
farbe="neutral"
/>
</div>
</div>
<Card
titel={status === "erledigt" ? "Erledigte Warnungen" : "Offene Warnungen"}
rahmen={status === "erledigt" ? "info" : "danger"}
tabelle
werkzeuge={
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
<Link
href="/warnungen"
className={`btn ${status !== "erledigt" ? "btn-primary" : "btn-outline-primary"}`}
>
Offen
</Link>
<Link
href="/warnungen?status=erledigt"
className={`btn ${status === "erledigt" ? "btn-primary" : "btn-outline-primary"}`}
>
Erledigt
</Link>
</div>
}
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Schwere</th>
<th scope="col">Warnung</th>
<th scope="col">Betrifft</th>
<th scope="col">Seit</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{alerts.map((alert) => (
<tr key={alert.id}>
<td>
<Badge farbe={SCHWERE_FARBE[alert.schwere]}>
{alert.schwere === "GEFAHR"
? "Gefahr"
: alert.schwere === "WARNUNG"
? "Warnung"
: "Hinweis"}
</Badge>
</td>
<td>
<span className="fw-semibold">{alert.titel}</span>
<div className="small text-body-secondary">{alert.beschreibung}</div>
<div className="small text-body-secondary pl-mono">{alert.regel}</div>
</td>
<td className="small">
{alert.host ? (
<div>
<Link href={`/hosts/${alert.host.id}`}>{alert.host.hostname}</Link>
</div>
) : null}
{alert.kunde ? (
<div>
<Link href={`/kunden/${alert.kunde.id}`}>{alert.kunde.name}</Link>
</div>
) : null}
{alert.laufzeit ? (
<div>
<Link href={`/rechnungen/${alert.laufzeit.id}`}>Laufzeit öffnen</Link>
</div>
) : null}
</td>
<td className="small">
{formatDatumZeit(alert.geoeffnetAm)}
{alert.quittiertAm ? <div className="text-body-secondary">quittiert</div> : null}
{alert.erledigtAm ? (
<div className="text-body-secondary">
erledigt {formatDatumZeit(alert.erledigtAm)}
</div>
) : null}
</td>
<td className="text-end">
{alert.erledigtAm ? null : (
<div className="d-flex gap-2 justify-content-end">
{alert.quittiertAm ? null : (
<form action={alertQuittieren}>
<input type="hidden" name="id" value={alert.id} />
<button type="submit" className="btn btn-sm btn-outline-secondary">
Quittieren
</button>
</form>
)}
<form action={alertSchliessen}>
<input type="hidden" name="id" value={alert.id} />
<button
type="submit"
className="btn btn-sm btn-outline-danger"
title="Schließt die Warnung; besteht die Ursache weiter, kommt sie beim nächsten Durchlauf zurück"
>
Schließen
</button>
</form>
</div>
)}
</td>
</tr>
))}
{alerts.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-body-secondary py-4">
{status === "erledigt" ? "Noch nichts erledigt." : "Keine offenen Warnungen."}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<p className="small text-body-secondary">
Die Regeln laufen stündlich im Worker und nach jedem eingehenden Report.{" "}
<Link href="/einstellungen/warnregeln">Schwellwerte ändern</Link>
</p>
</div>
</div>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { readFile } from "node:fs/promises";
import { prisma } from "@/lib/db";
import { anhangPfad } from "@/lib/uploads";
export const dynamic = "force-dynamic";
/** Liefert ein hinterlegtes Rechnungs-PDF aus. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const anhang = await prisma.anhang.findUnique({ where: { id } });
if (!anhang) return new Response("Nicht gefunden", { status: 404 });
try {
const inhalt = await readFile(anhangPfad(anhang.pfad));
return new Response(new Uint8Array(inhalt), {
headers: {
"Content-Type": anhang.mimeTyp,
"Content-Disposition": `inline; filename="${encodeURIComponent(anhang.dateiname)}"`,
"Content-Length": String(inhalt.length),
"Cache-Control": "private, no-store",
},
});
} catch {
return new Response("Datei fehlt auf der Platte", { status: 410 });
}
}
+13
View File
@@ -10,6 +10,8 @@ import {
} from "@/server/agent/http"; } from "@/server/agent/http";
import { reportSchema } from "@/server/agent/schema"; import { reportSchema } from "@/server/agent/schema";
import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service"; import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service";
import { alertsAbgleichen } from "@/server/alerts/abgleich";
import { neueAlertsMelden } from "@/server/notifications/versand";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,6 +32,17 @@ export async function POST(request: Request) {
const host = await authentifiziereHost(token); const host = await authentifiziereHost(token);
const ergebnis = await verarbeiteReport(host, eingabe.daten, clientIp(request.headers)); const ergebnis = await verarbeiteReport(host, eingabe.daten, clientIp(request.headers));
// Warnungen sofort neu bewerten – ein Report ist der häufigste Auslöser für Änderungen.
// Schlägt das fehl, ist der Report trotzdem angenommen.
try {
const auswertung = await alertsAbgleichen();
if (auswertung.geoeffnet.length > 0) {
await neueAlertsMelden(auswertung.geoeffnet.map((alert) => alert.fingerprint));
}
} catch (fehler) {
console.error("Auswertung der Warnregeln nach einem Report fehlgeschlagen:", fehler);
}
return Response.json({ return Response.json({
ok: true, ok: true,
hostId: host.id, hostId: host.id,
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useState, useTransition } from "react";
import { alertsJetztPruefen } from "@/server/actions/alerts";
/** Stößt die Auswertung sofort an, statt auf den nächsten Durchlauf des Workers zu warten. */
export function PruefenKnopf() {
const [meldung, setMeldung] = useState<string | null>(null);
const [laeuft, starte] = useTransition();
return (
<span className="d-flex align-items-center gap-2">
<button
type="button"
className="btn btn-sm btn-outline-primary"
disabled={laeuft}
onClick={() =>
starte(async () => {
const ergebnis = await alertsJetztPruefen();
setMeldung(ergebnis.erfolg ?? ergebnis.fehler ?? null);
})
}
>
<i className="bi bi-arrow-clockwise me-1" aria-hidden="true" />
{laeuft ? "Prüft …" : "Jetzt prüfen"}
</button>
{meldung ? <span className="small text-body-secondary">{meldung}</span> : null}
</span>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useActionState } from "react";
import { warnregelnSpeichern } from "@/server/actions/alerts";
import type { AktionsErgebnis } from "@/server/actions/kunden";
export interface RegelAnzeige {
name: string;
beschreibung: string;
aktiv: boolean;
/** Schwellwerte als Text, mehrere Stufen mit Komma getrennt. */
schwellwerte: { name: string; beschriftung: string; wert: string }[];
}
const LEER: AktionsErgebnis = {};
export function WarnregelFormular({ regeln }: { regeln: RegelAnzeige[] }) {
const [ergebnis, aktion, laeuft] = useActionState(warnregelnSpeichern, LEER);
return (
<form action={aktion}>
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
<div className="table-responsive">
<table className="table align-middle">
<thead>
<tr>
<th scope="col" style={{ width: "6rem" }}>
Aktiv
</th>
<th scope="col">Regel</th>
<th scope="col" style={{ width: "22rem" }}>
Schwellwerte
</th>
</tr>
</thead>
<tbody>
{regeln.map((regel) => (
<tr key={regel.name}>
<td>
<input
type="checkbox"
className="form-check-input pl-touch-target"
name={`aktiv:${regel.name}`}
defaultChecked={regel.aktiv}
aria-label={`${regel.beschreibung} aktiv`}
/>
</td>
<td>
<span className="fw-semibold">{regel.beschreibung}</span>
<div className="small text-body-secondary pl-mono">{regel.name}</div>
</td>
<td>
{regel.schwellwerte.length === 0 ? (
<span className="small text-body-secondary">keine</span>
) : (
regel.schwellwerte.map((schwelle) => (
<div className="mb-2" key={schwelle.name}>
<label className="form-label small mb-1" htmlFor={`${regel.name}-${schwelle.name}`}>
{schwelle.beschriftung}
</label>
<input
id={`${regel.name}-${schwelle.name}`}
name={`schwelle:${regel.name}:${schwelle.name}`}
className="form-control form-control-sm"
defaultValue={schwelle.wert}
/>
</div>
))
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button type="submit" className="btn btn-primary pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Speichern und neu auswerten"}
</button>
</form>
);
}
@@ -0,0 +1,217 @@
"use client";
import { useActionState } from "react";
import { emailKanalSpeichern, webhookKanalSpeichern } from "@/server/actions/benachrichtigungen";
import type { AktionsErgebnis } from "@/server/actions/kunden";
const LEER: AktionsErgebnis = {};
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) return <div className="alert alert-danger py-2">{ergebnis.fehler}</div>;
if (ergebnis.erfolg) return <div className="alert alert-success py-2">{ergebnis.erfolg}</div>;
return null;
}
export interface EmailWerte {
id: string | null;
name: string;
aktiv: boolean;
host: string;
port: number;
benutzer: string;
absender: string;
empfaenger: string;
passwortGesetzt: boolean;
}
export function EmailFormular({ werte }: { werte: EmailWerte }) {
const [ergebnis, aktion, laeuft] = useActionState(emailKanalSpeichern, LEER);
return (
<form action={aktion}>
{werte.id ? <input type="hidden" name="id" value={werte.id} /> : null}
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-name">
Bezeichnung
</label>
<input id="smtp-name" name="name" className="form-control" defaultValue={werte.name} />
</div>
<div className="col-md-5">
<label className="form-label" htmlFor="smtp-host">
SMTP-Server *
</label>
<input
id="smtp-host"
name="host"
className="form-control"
defaultValue={werte.host}
placeholder="smtp.office365.com"
required
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="smtp-port">
Port
</label>
<input
id="smtp-port"
name="port"
type="number"
className="form-control"
defaultValue={werte.port}
/>
<div className="form-text">587 = STARTTLS (Microsoft 365), 465 = SMTPS</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-benutzer">
Benutzer
</label>
<input
id="smtp-benutzer"
name="benutzer"
className="form-control"
defaultValue={werte.benutzer}
autoComplete="username"
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-passwort">
Passwort
</label>
<input
id="smtp-passwort"
name="passwort"
type="password"
className="form-control"
autoComplete="new-password"
placeholder={werte.passwortGesetzt ? "gespeichert – leer lassen zum Beibehalten" : ""}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="smtp-absender">
Absender
</label>
<input
id="smtp-absender"
name="absender"
className="form-control"
defaultValue={werte.absender}
placeholder="proxledger@example.de"
/>
</div>
<div className="col-md-9">
<label className="form-label" htmlFor="smtp-empfaenger">
Empfänger *
</label>
<input
id="smtp-empfaenger"
name="empfaenger"
className="form-control"
defaultValue={werte.empfaenger}
placeholder="florian@example.de, team@example.de"
required
/>
<div className="form-text">Mehrere Adressen mit Komma trennen.</div>
</div>
<div className="col-md-3 d-flex align-items-center">
<div className="form-check mt-4">
<input
className="form-check-input"
type="checkbox"
id="smtp-aktiv"
name="aktiv"
defaultChecked={werte.aktiv}
/>
<label className="form-check-label" htmlFor="smtp-aktiv">
aktiv
</label>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "E-Mail-Versand speichern"}
</button>
</form>
);
}
export interface WebhookWerte {
id: string | null;
name: string;
aktiv: boolean;
url: string;
geheimnisGesetzt: boolean;
}
export function WebhookFormular({ werte }: { werte: WebhookWerte }) {
const [ergebnis, aktion, laeuft] = useActionState(webhookKanalSpeichern, LEER);
return (
<form action={aktion}>
{werte.id ? <input type="hidden" name="id" value={werte.id} /> : null}
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-4">
<label className="form-label" htmlFor="hook-name">
Bezeichnung
</label>
<input id="hook-name" name="name" className="form-control" defaultValue={werte.name} />
</div>
<div className="col-md-8">
<label className="form-label" htmlFor="hook-url">
Adresse *
</label>
<input
id="hook-url"
name="url"
className="form-control"
defaultValue={werte.url}
placeholder="https://matrix.example.de/hook/abc"
required
/>
</div>
<div className="col-md-9">
<label className="form-label" htmlFor="hook-geheimnis">
Geheimnis für die Signatur
</label>
<input
id="hook-geheimnis"
name="geheimnis"
type="password"
className="form-control"
autoComplete="new-password"
placeholder={werte.geheimnisGesetzt ? "gespeichert – leer lassen zum Beibehalten" : "optional"}
/>
<div className="form-text">
Wird als <span className="pl-mono">X-ProxLedger-Signatur: sha256=…</span> mitgeschickt.
</div>
</div>
<div className="col-md-3 d-flex align-items-center">
<div className="form-check mt-4">
<input
className="form-check-input"
type="checkbox"
id="hook-aktiv"
name="aktiv"
defaultChecked={werte.aktiv}
/>
<label className="form-check-label" htmlFor="hook-aktiv">
aktiv
</label>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Webhook speichern"}
</button>
</form>
);
}
+6 -2
View File
@@ -150,7 +150,7 @@ export function Header({
{alerts.slice(0, 6).map((alert) => ( {alerts.slice(0, 6).map((alert) => (
<Link <Link
key={alert.id} key={alert.id}
href={alert.hostId ? `/hosts/${alert.hostId}` : "/hosts"} href={alert.hostId ? `/hosts/${alert.hostId}` : "/warnungen"}
className="dropdown-item d-flex gap-2 py-2" className="dropdown-item d-flex gap-2 py-2"
onClick={() => setGlockeOffen(false)} onClick={() => setGlockeOffen(false)}
> >
@@ -162,7 +162,11 @@ export function Header({
</Link> </Link>
))} ))}
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<Link href="/hosts" className="dropdown-item text-center" onClick={() => setGlockeOffen(false)}> <Link
href="/warnungen"
className="dropdown-item text-center"
onClick={() => setGlockeOffen(false)}
>
Alle Warnungen ansehen Alle Warnungen ansehen
</Link> </Link>
</div> </div>
+9
View File
@@ -15,10 +15,12 @@ interface MenuEintrag {
export function Sidebar({ export function Sidebar({
hostsAnzahl, hostsAnzahl,
offeneRechnungspunkte, offeneRechnungspunkte,
offeneWarnungen,
benutzer, benutzer,
}: { }: {
hostsAnzahl: number; hostsAnzahl: number;
offeneRechnungspunkte: number; offeneRechnungspunkte: number;
offeneWarnungen: number;
benutzer: string; benutzer: string;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -44,10 +46,17 @@ export function Sidebar({
: undefined, : undefined,
}, },
{ href: "/jahresbahn", titel: "Jahresbahn", icon: "bi-calendar3-range" }, { href: "/jahresbahn", titel: "Jahresbahn", icon: "bi-calendar3-range" },
{
href: "/warnungen",
titel: "Warnungen",
icon: "bi-bell",
badge: offeneWarnungen > 0 ? { text: String(offeneWarnungen), klasse: "pl-bg-warning" } : undefined,
},
]; ];
const einstellungen: MenuEintrag[] = [ const einstellungen: MenuEintrag[] = [
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" }, { href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
{ href: "/einstellungen/warnregeln", titel: "Warnregeln", icon: "bi-shield-exclamation" },
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" }, { href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" }, { href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
]; ];
@@ -0,0 +1,345 @@
"use client";
import { useActionState } from "react";
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
import type { AktionsErgebnis } from "@/server/actions/kunden";
const LEER: AktionsErgebnis = {};
function isoDatum(wert: string | null | undefined) {
return wert ? wert.slice(0, 10) : "";
}
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) return <div className="alert alert-danger py-2">{ergebnis.fehler}</div>;
if (ergebnis.erfolg) return <div className="alert alert-success py-2">{ergebnis.erfolg}</div>;
return null;
}
export interface ProxmoxWerte {
nummer: string | null;
datum: string | null;
faelligAm: string | null;
nettoBetrag: number | null;
ustSatz: number;
rabatt: number | null;
zahlungsart: string | null;
bezahlt: boolean;
bezahltAm: string | null;
anhangId: string | null;
anhangName: string | null;
}
/** Proxmox-Rechnung (Einkauf). Gleiche Rechnungsnummer heißt: Position an derselben Rechnung. */
export function ProxmoxRechnungFormular({
laufzeitId,
hostId,
werte,
}: {
laufzeitId: string;
hostId: string;
werte: ProxmoxWerte;
}) {
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
return (
<form action={aktion}>
<input type="hidden" name="laufzeitId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-6">
<label className="form-label" htmlFor="px-nummer">
Rechnungsnummer *
</label>
<input
id="px-nummer"
name="nummer"
className="form-control pl-mono"
defaultValue={werte.nummer ?? ""}
placeholder="PX-2026-14790"
required
/>
<div className="form-text">
Eine bereits vorhandene Nummer ergänzt die Sammelrechnung um diese Laufzeit.
</div>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="px-datum">
Rechnungsdatum *
</label>
<input
id="px-datum"
name="datum"
type="date"
className="form-control"
defaultValue={isoDatum(werte.datum)}
required
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="px-faellig">
Fällig am
</label>
<input
id="px-faellig"
name="faelligAm"
type="date"
className="form-control"
defaultValue={isoDatum(werte.faelligAm)}
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="px-netto">
Netto (€) *
</label>
<input
id="px-netto"
name="nettoBetrag"
className="form-control"
inputMode="decimal"
defaultValue={werte.nettoBetrag ?? ""}
required
/>
</div>
<div className="col-md-2">
<label className="form-label" htmlFor="px-ust">
USt (%)
</label>
<input
id="px-ust"
name="ustSatz"
className="form-control"
inputMode="decimal"
defaultValue={werte.ustSatz}
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="px-rabatt">
Rabatt (€)
</label>
<input
id="px-rabatt"
name="rabatt"
className="form-control"
inputMode="decimal"
defaultValue={werte.rabatt ?? ""}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="px-zahlungsart">
Zahlungsart
</label>
<input
id="px-zahlungsart"
name="zahlungsart"
className="form-control"
defaultValue={werte.zahlungsart ?? ""}
placeholder="Überweisung"
/>
</div>
<div className="col-md-4 d-flex align-items-end">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="px-bezahlt"
name="bezahlt"
defaultChecked={werte.bezahlt}
/>
<label className="form-check-label" htmlFor="px-bezahlt">
bezahlt
</label>
</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="px-bezahltAm">
Bezahlt am
</label>
<input
id="px-bezahltAm"
name="bezahltAm"
type="date"
className="form-control"
defaultValue={isoDatum(werte.bezahltAm)}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="px-pdf">
PDF (max. 10 MB)
</label>
<input id="px-pdf" name="pdf" type="file" accept="application/pdf" className="form-control" />
{werte.anhangId ? (
<div className="form-text">
Hinterlegt:{" "}
<a href={`/anhang/${werte.anhangId}`} target="_blank" rel="noreferrer">
{werte.anhangName ?? "PDF ansehen"}
</a>
</div>
) : null}
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Proxmox-Rechnung speichern"}
</button>
</form>
);
}
export interface KundenWerte {
erstellt: boolean;
nummer: string | null;
datum: string | null;
nettoBetrag: number | null;
ustSatz: number;
bezahlt: boolean;
bezahltAm: string | null;
anhangId: string | null;
anhangName: string | null;
}
/** Kundenrechnung (Verkauf). */
export function KundenrechnungFormular({
laufzeitId,
hostId,
werte,
kundeVorhanden,
}: {
laufzeitId: string;
hostId: string;
werte: KundenWerte;
kundeVorhanden: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState(kundenrechnungSpeichern, LEER);
if (!kundeVorhanden) {
return (
<p className="mb-0 text-body-secondary">
Dieser Host ist keinem Kunden zugewiesen. Ohne Kunde lässt sich keine Kundenrechnung erfassen.
</p>
);
}
return (
<form action={aktion}>
<input type="hidden" name="laufzeitId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-4 d-flex align-items-end">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="kr-erstellt"
name="erstellt"
defaultChecked={werte.erstellt}
/>
<label className="form-check-label" htmlFor="kr-erstellt">
Rechnung ist erstellt
</label>
</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="kr-nummer">
Rechnungsnummer
</label>
<input
id="kr-nummer"
name="nummer"
className="form-control pl-mono"
defaultValue={werte.nummer ?? ""}
placeholder="RE-2026-0412"
/>
<div className="form-text">Gleiche Nummer = eine Rechnung über mehrere Laufzeiten.</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="kr-datum">
Rechnungsdatum
</label>
<input
id="kr-datum"
name="datum"
type="date"
className="form-control"
defaultValue={isoDatum(werte.datum)}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="kr-netto">
Netto (€)
</label>
<input
id="kr-netto"
name="nettoBetrag"
className="form-control"
inputMode="decimal"
defaultValue={werte.nettoBetrag ?? ""}
/>
</div>
<div className="col-md-2">
<label className="form-label" htmlFor="kr-ust">
USt (%)
</label>
<input
id="kr-ust"
name="ustSatz"
className="form-control"
inputMode="decimal"
defaultValue={werte.ustSatz}
/>
</div>
<div className="col-md-3 d-flex align-items-end">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="kr-bezahlt"
name="bezahlt"
defaultChecked={werte.bezahlt}
/>
<label className="form-check-label" htmlFor="kr-bezahlt">
vom Kunden bezahlt
</label>
</div>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="kr-bezahltAm">
Bezahlt am
</label>
<input
id="kr-bezahltAm"
name="bezahltAm"
type="date"
className="form-control"
defaultValue={isoDatum(werte.bezahltAm)}
/>
</div>
<div className="col-md-6">
<label className="form-label" htmlFor="kr-pdf">
PDF (max. 10 MB)
</label>
<input id="kr-pdf" name="pdf" type="file" accept="application/pdf" className="form-control" />
{werte.anhangId ? (
<div className="form-text">
Hinterlegt:{" "}
<a href={`/anhang/${werte.anhangId}`} target="_blank" rel="noreferrer">
{werte.anhangName ?? "PDF ansehen"}
</a>
</div>
) : null}
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Kundenrechnung speichern"}
</button>
</form>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useTransition } from "react";
import { statusUmschalten } from "@/server/actions/rechnungen";
/**
* Häkchen, das den Rechnungsstatus direkt in der Tabelle setzt.
* Ohne erfasste Rechnung bleibt es deaktiviert – außer bei „Kundenrechnung erstellt“,
* denn dort legt das Häkchen eine Rechnung ohne Nummer an, die später ergänzt wird.
*/
export function StatusHaken({
laufzeitId,
hostId,
feld,
wert,
deaktiviert = false,
titel,
}: {
laufzeitId: string;
hostId: string;
feld: "einkaufBezahlt" | "verkaufErstellt" | "verkaufBezahlt";
wert: boolean;
deaktiviert?: boolean;
titel: string;
}) {
const [laeuft, starte] = useTransition();
function umschalten(neu: boolean) {
const daten = new FormData();
daten.set("laufzeitId", laufzeitId);
daten.set("hostId", hostId);
daten.set("feld", feld);
daten.set("wert", String(neu));
starte(async () => {
await statusUmschalten(daten);
});
}
return (
<span className="d-inline-flex align-items-center justify-content-center">
{laeuft ? (
<span
className="spinner-border spinner-border-sm text-body-secondary"
role="status"
aria-hidden="true"
/>
) : (
<input
type="checkbox"
className="form-check-input pl-touch-target"
checked={wert}
disabled={deaktiviert}
onChange={(e) => umschalten(e.target.checked)}
aria-label={titel}
title={deaktiviert ? "Erst eine Rechnung erfassen" : titel}
/>
)}
</span>
);
}
+36 -9
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState, useTransition } from "react";
import { statusUmschalten } from "@/server/actions/rechnungen";
import { Badge } from "./Badges"; import { Badge } from "./Badges";
import type { Farbton } from "./SmallBox"; import type { Farbton } from "./SmallBox";
@@ -12,6 +13,12 @@ export interface Aufgabe {
dringlichkeit: "gefahr" | "warnung" | "info"; dringlichkeit: "gefahr" | "warnung" | "info";
badgeText: string; badgeText: string;
href: string; href: string;
/** Gesetzt, wenn das Abhaken direkt einen Rechnungsstatus setzt. */
aktion?: {
laufzeitId: string;
hostId: string;
feld: "einkaufBezahlt" | "verkaufErstellt";
};
} }
const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = { const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = {
@@ -21,14 +28,30 @@ const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = {
}; };
/** /**
* Aufgabenliste im AdminLTE-Todo-Stil. Im Prototyp merkt sich das Abhaken den Zustand nur lokal; * Aufgabenliste im AdminLTE-Todo-Stil. Aufgaben mit `aktion` setzen beim Abhaken den
* ab #008 setzt es den Rechnungsstatus in der Datenbank. * Rechnungsstatus in der Datenbank; die übrigen sind Merkposten und verschwinden erst,
* wenn die Ursache behoben ist (z. B. ein Host einem Kunden zugewiesen wurde).
*/ */
export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) { export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
const [erledigt, setErledigt] = useState<string[]>([]); const [oertlichErledigt, setOertlichErledigt] = useState<string[]>([]);
const [laeuft, starte] = useTransition();
function umschalten(id: string) { function umschalten(aufgabe: Aufgabe) {
setErledigt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id])); if (!aufgabe.aktion) {
setOertlichErledigt((alt) =>
alt.includes(aufgabe.id) ? alt.filter((x) => x !== aufgabe.id) : [...alt, aufgabe.id],
);
return;
}
const daten = new FormData();
daten.set("laufzeitId", aufgabe.aktion.laufzeitId);
daten.set("hostId", aufgabe.aktion.hostId);
daten.set("feld", aufgabe.aktion.feld);
daten.set("wert", "true");
starte(async () => {
await statusUmschalten(daten);
});
} }
if (aufgaben.length === 0) { if (aufgaben.length === 0) {
@@ -38,7 +61,7 @@ export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
return ( return (
<ul className="pl-todo-list"> <ul className="pl-todo-list">
{aufgaben.map((aufgabe) => { {aufgaben.map((aufgabe) => {
const istErledigt = erledigt.includes(aufgabe.id); const istErledigt = oertlichErledigt.includes(aufgabe.id);
return ( return (
<li <li
key={aufgabe.id} key={aufgabe.id}
@@ -48,8 +71,12 @@ export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
type="checkbox" type="checkbox"
className="form-check-input mt-1 pl-touch-target" className="form-check-input mt-1 pl-touch-target"
checked={istErledigt} checked={istErledigt}
onChange={() => umschalten(aufgabe.id)} disabled={laeuft}
aria-label={`${aufgabe.titel} erledigt`} onChange={() => umschalten(aufgabe)}
aria-label={
aufgabe.aktion ? `${aufgabe.titel} als erledigt eintragen` : `${aufgabe.titel} abhaken`
}
title={aufgabe.aktion ? "Setzt den Rechnungsstatus" : "Nur zur eigenen Übersicht"}
/> />
<div className="flex-grow-1 pl-todo-text"> <div className="flex-grow-1 pl-todo-text">
<Link href={aufgabe.href} className="fw-semibold text-decoration-none"> <Link href={aufgabe.href} className="fw-semibold text-decoration-none">
+53
View File
@@ -0,0 +1,53 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { prisma } from "./db";
/** Rechnungs-PDFs liegen auf der Platte, nur die Metadaten in der Datenbank. */
export const UPLOAD_VERZEICHNIS = process.env.UPLOAD_DIR ?? path.join(process.cwd(), "data", "uploads");
export const MAX_GROESSE = 10 * 1024 * 1024;
export class UploadFehler extends Error {}
/**
* Nimmt ein PDF entgegen und legt es unter seiner Prüfsumme ab.
* Gleiche Datei zweimal hochgeladen heißt: derselbe Anhang, kein zweites Exemplar auf der Platte.
*/
export async function anhangSpeichern(datei: File): Promise<string> {
if (datei.size === 0) throw new UploadFehler("Die Datei ist leer.");
if (datei.size > MAX_GROESSE) {
throw new UploadFehler(`Die Datei ist größer als ${MAX_GROESSE / 1024 / 1024} MB.`);
}
const inhalt = Buffer.from(await datei.arrayBuffer());
// Nicht auf den gemeldeten Typ verlassen, sondern in die Datei sehen.
if (inhalt.subarray(0, 5).toString("latin1") !== "%PDF-") {
throw new UploadFehler("Nur PDF-Dateien sind erlaubt.");
}
const sha256 = createHash("sha256").update(inhalt).digest("hex");
const vorhanden = await prisma.anhang.findFirst({ where: { sha256 } });
if (vorhanden) return vorhanden.id;
await mkdir(UPLOAD_VERZEICHNIS, { recursive: true });
const dateiname = `${sha256}.pdf`;
await writeFile(path.join(UPLOAD_VERZEICHNIS, dateiname), inhalt, { mode: 0o640 });
const anhang = await prisma.anhang.create({
data: {
dateiname: datei.name || dateiname,
pfad: dateiname,
mimeTyp: "application/pdf",
groesse: inhalt.length,
sha256,
},
});
return anhang.id;
}
export function anhangPfad(pfad: string): string {
// Nur der Dateiname aus der Datenbank wird verwendet; Pfadangaben darin werden verworfen.
return path.join(UPLOAD_VERZEICHNIS, path.basename(pfad));
}
+93
View File
@@ -0,0 +1,93 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { alertsAbgleichen } from "@/server/alerts/abgleich";
import { REGELN } from "@/server/alerts/regeln";
import type { AktionsErgebnis } from "./kunden";
function neuLaden() {
revalidatePath("/warnungen");
revalidatePath("/einstellungen/warnregeln");
revalidatePath("/");
}
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
export async function alertQuittieren(formular: FormData) {
const id = formular.get("id")?.toString();
if (!id) return;
await prisma.alert.update({ where: { id }, data: { quittiertAm: new Date() } });
await protokolliere("alert.quittiert", "Alert", id, {});
neuLaden();
}
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
export async function alertSchliessen(formular: FormData) {
const id = formular.get("id")?.toString();
if (!id) return;
await prisma.alert.update({ where: { id }, data: { erledigtAm: new Date() } });
await protokolliere("alert.geschlossen", "Alert", id, {});
neuLaden();
}
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
const ergebnis = await alertsAbgleichen();
neuLaden();
return {
erfolg: `${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert.`,
};
}
/** Schwellwerte und Ein-/Ausschalter der Regeln speichern. */
export async function warnregelnSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
for (const regel of REGELN) {
const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
const schwellwerte: Record<string, number | number[]> = {};
for (const [name, standard] of Object.entries(regel.standardSchwellwerte)) {
const eingabe = formular.get(`schwelle:${regel.name}:${name}`)?.toString().trim();
if (!eingabe) continue;
if (Array.isArray(standard)) {
const zahlen = eingabe
.split(",")
.map((teil) => Number(teil.trim()))
.filter((zahl) => Number.isFinite(zahl) && zahl > 0)
.sort((a, b) => b - a);
if (zahlen.length === 0) {
return { fehler: `Bei „${regel.beschreibung}“ bitte mindestens einen Wert angeben.` };
}
schwellwerte[name] = zahlen;
} else {
const zahl = Number(eingabe);
if (!Number.isFinite(zahl) || zahl <= 0) {
return { fehler: `Bei „${regel.beschreibung}“ bitte eine Zahl größer null angeben.` };
}
schwellwerte[name] = zahl;
}
}
await prisma.warnregelEinstellung.upsert({
where: { regel: regel.name },
update: { aktiv, schwellwerte },
create: { regel: regel.name, aktiv, schwellwerte },
});
}
await protokolliere("warnregeln.geaendert", "WarnregelEinstellung", null, {});
// Änderungen sollen sofort sichtbar sein.
const ergebnis = await alertsAbgleichen();
neuLaden();
return {
erfolg: `Gespeichert. Auswertung: ${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt.`,
};
}
+158
View File
@@ -0,0 +1,158 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten";
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
import type { AktionsErgebnis } from "./kunden";
function neuLaden() {
revalidatePath("/einstellungen/benachrichtigungen");
}
function appUrl() {
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
}
/** Legt den E-Mail-Kanal an oder ändert ihn. Ein leeres Passwortfeld lässt das gespeicherte unangetastet. */
export async function emailKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const id = formular.get("id")?.toString() || null;
const host = formular.get("host")?.toString().trim() ?? "";
const port = Number(formular.get("port") ?? 587);
const empfaenger = (formular.get("empfaenger")?.toString() ?? "")
.split(/[,;\s]+/)
.map((eintrag) => eintrag.trim())
.filter(Boolean);
if (!host) return { fehler: "Bitte den SMTP-Server angeben." };
if (!Number.isInteger(port) || port < 1 || port > 65535) return { fehler: "Ungültiger Port." };
if (empfaenger.length === 0) return { fehler: "Bitte mindestens einen Empfänger angeben." };
const bisher = id
? ((await prisma.benachrichtigungskanal.findUnique({ where: { id } }))?.konfiguration as unknown as
EmailKonfiguration | undefined)
: undefined;
const passwortEingabe = formular.get("passwort")?.toString() ?? "";
const konfiguration: EmailKonfiguration = {
host,
port,
secure: port === 465,
benutzer: formular.get("benutzer")?.toString().trim() ?? "",
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden.
passwort: passwortEingabe || (bisher?.passwort ?? ""),
absender: formular.get("absender")?.toString().trim() ?? "",
empfaenger,
};
const daten = {
typ: "EMAIL" as const,
name: formular.get("name")?.toString().trim() || "E-Mail",
aktiv: formular.get("aktiv") === "on",
konfiguration: JSON.parse(JSON.stringify(konfiguration)),
};
const kanal = id
? await prisma.benachrichtigungskanal.update({ where: { id }, data: daten })
: await prisma.benachrichtigungskanal.create({ data: daten });
await protokolliere("benachrichtigungskanal.gespeichert", "Benachrichtigungskanal", kanal.id, {
typ: "EMAIL",
host,
empfaenger,
});
neuLaden();
return { erfolg: "E-Mail-Versand gespeichert." };
}
export async function webhookKanalSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const id = formular.get("id")?.toString() || null;
const url = formular.get("url")?.toString().trim() ?? "";
if (!/^https?:\/\//.test(url)) return { fehler: "Bitte eine vollständige Adresse mit https:// angeben." };
const bisher = id
? ((await prisma.benachrichtigungskanal.findUnique({ where: { id } }))?.konfiguration as unknown as
WebhookKonfiguration | undefined)
: undefined;
const geheimnisEingabe = formular.get("geheimnis")?.toString() ?? "";
const konfiguration: WebhookKonfiguration = {
url,
geheimnis: geheimnisEingabe || bisher?.geheimnis,
};
const daten = {
typ: "WEBHOOK" as const,
name: formular.get("name")?.toString().trim() || "Webhook",
aktiv: formular.get("aktiv") === "on",
konfiguration: JSON.parse(JSON.stringify(konfiguration)),
};
const kanal = id
? await prisma.benachrichtigungskanal.update({ where: { id }, data: daten })
: await prisma.benachrichtigungskanal.create({ data: daten });
await protokolliere("benachrichtigungskanal.gespeichert", "Benachrichtigungskanal", kanal.id, {
typ: "WEBHOOK",
url,
});
neuLaden();
return { erfolg: "Webhook gespeichert." };
}
export async function kanalLoeschen(formular: FormData) {
const id = formular.get("id")?.toString();
if (!id) return;
await prisma.benachrichtigungskanal.delete({ where: { id } });
await protokolliere("benachrichtigungskanal.geloescht", "Benachrichtigungskanal", id, {});
neuLaden();
}
/** Verschickt eine Testnachricht über einen Kanal. */
export async function testSenden(formular: FormData): Promise<void> {
const id = formular.get("id")?.toString();
if (!id) return;
try {
await ueberEinenKanalSenden(id, testNachricht(appUrl()));
} catch (fehler) {
// Das Ergebnis steht im Versandprotokoll auf der Seite – dort ist auch der Fehlertext zu sehen.
console.error("Testversand fehlgeschlagen:", fehler);
}
neuLaden();
}
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
export async function zusammenfassungSpeichern(formular: FormData) {
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
await prisma.einstellung.upsert({
where: { schluessel: "zusammenfassung" },
update: { wert: { zeitraum } },
create: { schluessel: "zusammenfassung", wert: { zeitraum } },
});
await protokolliere("zusammenfassung.geaendert", "Einstellung", "zusammenfassung", { zeitraum });
neuLaden();
}
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
await zusammenfassungSenden(zeitraum);
neuLaden();
}
+168
View File
@@ -0,0 +1,168 @@
"use server";
import { revalidatePath } from "next/cache";
import { anhangSpeichern, UploadFehler } from "@/lib/uploads";
import {
einkaufBezahltSetzen,
kundenrechnungErfassen,
laufzeitAnlegen,
laufzeitBestaetigen,
laufzeitVerwerfen,
proxmoxRechnungErfassen,
RechnungsFehler,
verkaufBezahltSetzen,
verkaufErstelltSetzen,
} from "@/server/services/rechnungen";
import type { AktionsErgebnis } from "./kunden";
function datum(wert: FormDataEntryValue | null): Date | null {
const text = wert?.toString().trim();
return text ? new Date(`${text}T00:00:00Z`) : null;
}
function betrag(wert: FormDataEntryValue | null): number | null {
const text = wert?.toString().trim().replace(",", ".");
if (!text) return null;
const zahl = Number(text);
return Number.isFinite(zahl) ? zahl : null;
}
function seitenNeuLaden(hostId?: string | null) {
revalidatePath("/rechnungen");
revalidatePath("/");
revalidatePath("/kunden");
if (hostId) revalidatePath(`/hosts/${hostId}`);
}
/** Wandelt Fehler aus der Fachlogik in eine Rückmeldung für das Formular. */
async function ausfuehren(arbeit: () => Promise<string | void>): Promise<AktionsErgebnis> {
try {
const meldung = await arbeit();
return { erfolg: meldung || "Gespeichert." };
} catch (fehler) {
if (fehler instanceof RechnungsFehler || fehler instanceof UploadFehler) {
return { fehler: fehler.message };
}
throw fehler;
}
}
export async function laufzeitBestaetigenAktion(formular: FormData) {
const id = formular.get("laufzeitId")?.toString();
if (!id) return;
await laufzeitBestaetigen(id);
seitenNeuLaden(formular.get("hostId")?.toString());
}
export async function laufzeitVerwerfenAktion(formular: FormData) {
const id = formular.get("laufzeitId")?.toString();
if (!id) return;
await laufzeitVerwerfen(id);
seitenNeuLaden(formular.get("hostId")?.toString());
}
/** Häkchen aus der Rechnungstabelle. */
export async function statusUmschalten(formular: FormData): Promise<void> {
const laufzeitId = formular.get("laufzeitId")?.toString();
const feld = formular.get("feld")?.toString();
const wert = formular.get("wert")?.toString() === "true";
if (!laufzeitId || !feld) return;
try {
if (feld === "einkaufBezahlt") await einkaufBezahltSetzen(laufzeitId, wert);
if (feld === "verkaufErstellt") await verkaufErstelltSetzen(laufzeitId, wert);
if (feld === "verkaufBezahlt") await verkaufBezahltSetzen(laufzeitId, wert);
} catch (fehler) {
if (!(fehler instanceof RechnungsFehler)) throw fehler;
// Fehlt die Rechnung, bleibt der Status unverändert; die Tabelle zeigt weiterhin „Fehlt“.
}
seitenNeuLaden(formular.get("hostId")?.toString());
}
export async function proxmoxRechnungSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
return ausfuehren(async () => {
const pdf = formular.get("pdf");
const anhangId = pdf instanceof File && pdf.size > 0 ? await anhangSpeichern(pdf) : null;
const rechnungsdatum = datum(formular.get("datum"));
if (!rechnungsdatum) throw new RechnungsFehler("Bitte ein Rechnungsdatum angeben.");
const netto = betrag(formular.get("nettoBetrag"));
if (netto === null) throw new RechnungsFehler("Bitte einen Nettobetrag angeben.");
const ergebnis = await proxmoxRechnungErfassen(laufzeitId, {
nummer: formular.get("nummer")?.toString() ?? "",
datum: rechnungsdatum,
faelligAm: datum(formular.get("faelligAm")),
nettoBetrag: netto,
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
rabatt: betrag(formular.get("rabatt")),
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
bezahlt: formular.get("bezahlt") === "on",
bezahltAm: datum(formular.get("bezahltAm")),
anhangId,
notizen: formular.get("notizen")?.toString().trim() || null,
});
seitenNeuLaden(formular.get("hostId")?.toString());
return ergebnis.sammelrechnung
? "Gespeichert – die Laufzeit hängt jetzt als weitere Position an dieser Sammelrechnung."
: "Proxmox-Rechnung gespeichert.";
});
}
export async function kundenrechnungSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
return ausfuehren(async () => {
const pdf = formular.get("pdf");
const anhangId = pdf instanceof File && pdf.size > 0 ? await anhangSpeichern(pdf) : null;
const ergebnis = await kundenrechnungErfassen(laufzeitId, {
erstellt: formular.get("erstellt") === "on",
nummer: formular.get("nummer")?.toString().trim() || null,
datum: datum(formular.get("datum")),
nettoBetrag: betrag(formular.get("nettoBetrag")),
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
bezahlt: formular.get("bezahlt") === "on",
bezahltAm: datum(formular.get("bezahltAm")),
anhangId,
notizen: formular.get("notizen")?.toString().trim() || null,
});
seitenNeuLaden(formular.get("hostId")?.toString());
return ergebnis.sammelrechnung
? "Gespeichert – die Laufzeit hängt jetzt als weitere Position an dieser Sammelrechnung."
: "Kundenrechnung gespeichert.";
});
}
export async function laufzeitAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const hostId = formular.get("hostId")?.toString();
const key = formular.get("key")?.toString().trim();
const start = datum(formular.get("start"));
const ende = datum(formular.get("ende"));
if (!hostId || !key || !start || !ende) {
return { fehler: "Bitte Host, Key, Beginn und Ende angeben." };
}
return ausfuehren(async () => {
await laufzeitAnlegen({ hostId, key, start, ende, notizen: null });
seitenNeuLaden(hostId);
return "Laufzeit angelegt.";
});
}
+153
View File
@@ -0,0 +1,153 @@
import { prisma } from "@/lib/db";
import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from "./regeln";
/**
* Lädt den Zustand, wendet die Regeln an und gleicht das Ergebnis mit den offenen Warnungen ab:
* neue öffnen, verschwundene schließen, bestehende unverändert lassen.
*/
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
const [hosts, laufzeiten, einstellungen] = await Promise.all([
prisma.host.findMany({
include: {
kunde: { select: { name: true } },
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
},
}),
prisma.laufzeit.findMany({
where: { status: "BESTAETIGT" },
include: {
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
}),
prisma.warnregelEinstellung.findMany(),
]);
return {
jetzt,
hosts: hosts.map((host) => ({
id: host.id,
hostname: host.hostname,
kundeId: host.kundeId,
kundeName: host.kunde?.name ?? null,
subKey: host.subKey,
subStatus: host.subStatus,
subNextduedate: host.subNextduedate,
subLizenzierteSockets: host.subLizenzierteSockets,
sockets: host.sockets,
letzterReportAm: host.letzterReportAm,
serverIds: host.serverIdHistorie,
})),
laufzeiten: laufzeiten.map((laufzeit) => ({
id: laufzeit.id,
hostId: laufzeit.hostId,
hostname: laufzeit.host.hostname,
kundeId: laufzeit.host.kundeId,
kundeName: laufzeit.host.kunde?.name ?? null,
key: laufzeit.key,
start: laufzeit.start,
ende: laufzeit.ende,
einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null,
einkaufBetrag: laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null,
einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null,
verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null,
verkaufBetrag: laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
verkaufDatum: laufzeit.kundenPosition?.rechnung.datum ?? null,
})),
einstellungen: new Map(
einstellungen.map((eintrag) => [
eintrag.regel,
{
aktiv: eintrag.aktiv,
schwellwerte: (eintrag.schwellwerte ?? {}) as Record<string, unknown>,
},
]),
),
};
}
export interface AbgleichErgebnis {
geoeffnet: AlertKandidat[];
erledigt: string[];
unveraendert: number;
}
/** Vergleicht die Kandidaten mit den offenen Warnungen und schreibt die Unterschiede fort. */
export async function alertsAbgleichen(jetzt: Date = new Date()): Promise<AbgleichErgebnis> {
const kontext = await kontextLaden(jetzt);
const kandidaten = alleRegelnPruefen(kontext);
const offene = await prisma.alert.findMany({ where: { erledigtAm: null } });
const offeneNachFingerprint = new Map(offene.map((alert) => [alert.fingerprint, alert]));
const kandidatenNachFingerprint = new Map(kandidaten.map((k) => [k.fingerprint, k]));
const neu = kandidaten.filter((k) => !offeneNachFingerprint.has(k.fingerprint));
const verschwunden = offene.filter((alert) => !kandidatenNachFingerprint.has(alert.fingerprint));
for (const kandidat of neu) {
// Ein früher erledigter Fall taucht wieder auf: denselben Eintrag erneut öffnen.
await prisma.alert.upsert({
where: { fingerprint: kandidat.fingerprint },
update: {
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
schwere: kandidat.schwere,
erledigtAm: null,
quittiertAm: null,
geoeffnetAm: jetzt,
},
create: {
fingerprint: kandidat.fingerprint,
regel: kandidat.regel,
schwere: kandidat.schwere,
titel: kandidat.titel,
beschreibung: kandidat.beschreibung,
hostId: kandidat.hostId,
kundeId: kandidat.kundeId,
laufzeitId: kandidat.laufzeitId,
geoeffnetAm: jetzt,
},
});
}
// Beschreibungen bestehender Warnungen nachführen (z. B. „läuft in 9 Tagen ab“).
for (const alert of offene) {
const kandidat = kandidatenNachFingerprint.get(alert.fingerprint);
if (!kandidat) continue;
if (kandidat.titel !== alert.titel || kandidat.beschreibung !== alert.beschreibung) {
await prisma.alert.update({
where: { id: alert.id },
data: { titel: kandidat.titel, beschreibung: kandidat.beschreibung, schwere: kandidat.schwere },
});
}
}
if (verschwunden.length > 0) {
await prisma.alert.updateMany({
where: { id: { in: verschwunden.map((alert) => alert.id) } },
data: { erledigtAm: jetzt },
});
}
return {
geoeffnet: neu,
erledigt: verschwunden.map((alert) => alert.fingerprint),
unveraendert: kandidaten.length - neu.length,
};
}
/** Legt fehlende Regel-Einstellungen mit ihren Standardwerten an (Seed und Aktualisierungen). */
export async function warnregelnSicherstellen() {
for (const regel of REGELN) {
await prisma.warnregelEinstellung.upsert({
where: { regel: regel.name },
update: {},
create: { regel: regel.name, aktiv: true, schwellwerte: regel.standardSchwellwerte },
});
}
}
+391
View File
@@ -0,0 +1,391 @@
import type { AlertSchwere, SubscriptionStatus } from "@/generated/prisma/enums";
import { formatDatum, formatEuro } from "@/lib/format";
/**
* Warnregeln als reine Funktionen: Sie bekommen einen Ausschnitt der Daten und geben
* Kandidaten zurück. Kein Datenbankzugriff, keine Nebenwirkungen – dadurch sind sie einzeln testbar.
* Das Öffnen und Schließen der Alerts übernimmt der Abgleich (`abgleich.ts`).
*/
export interface AlertKandidat {
/** Regel + betroffenes Objekt (+ Stufe). Zwei Kandidaten mit gleichem Fingerabdruck sind derselbe Fall. */
fingerprint: string;
regel: string;
schwere: AlertSchwere;
titel: string;
beschreibung: string;
hostId: string | null;
kundeId: string | null;
laufzeitId: string | null;
}
export interface HostDaten {
id: string;
hostname: string;
kundeId: string | null;
kundeName: string | null;
subKey: string | null;
subStatus: SubscriptionStatus;
subNextduedate: Date | null;
subLizenzierteSockets: number | null;
sockets: number | null;
letzterReportAm: Date | null;
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[];
}
export interface LaufzeitDaten {
id: string;
hostId: string;
hostname: string;
kundeId: string | null;
kundeName: string | null;
key: string;
start: Date;
ende: Date;
einkaufNummer: string | null;
einkaufBetrag: number | null;
einkaufBezahlt: boolean | null;
einkaufFaelligAm: Date | null;
verkaufNummer: string | null;
verkaufBetrag: number | null;
verkaufErstellt: boolean;
verkaufBezahlt: boolean;
verkaufDatum: Date | null;
}
export interface RegelKontext {
jetzt: Date;
hosts: HostDaten[];
laufzeiten: LaufzeitDaten[];
einstellungen: Map<string, { aktiv: boolean; schwellwerte: Record<string, unknown> }>;
}
export interface Regel {
name: string;
beschreibung: string;
/** Wird beim Anlegen der Einstellung als Vorgabe gespeichert (JSON-taugliche Werte). */
standardSchwellwerte: Record<string, number | number[] | string | boolean>;
pruefe: (kontext: RegelKontext) => AlertKandidat[];
}
const TAG = 86_400_000;
function tageBis(datum: Date, jetzt: Date): number {
const ziel = Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate());
const start = Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate());
return Math.round((ziel - start) / TAG);
}
function schwellwert<T>(kontext: RegelKontext, regel: string, name: string, standard: T): T {
const wert = kontext.einstellungen.get(regel)?.schwellwerte?.[name];
return (wert as T) ?? standard;
}
function kundenHinweis(kundeName: string | null): string {
return kundeName ?? "Nicht zugeordnet";
}
// ---------------------------------------------------------------- Regeln
const subscriptionLaeuftAb: Regel = {
name: "subscription-laeuft-ab",
beschreibung: "Subscription läuft in wenigen Tagen ab",
standardSchwellwerte: { tage: [30, 14, 7] },
pruefe(kontext) {
const stufen = [...schwellwert<number[]>(kontext, "subscription-laeuft-ab", "tage", [30, 14, 7])].sort(
(a, b) => b - a,
);
return kontext.hosts.flatMap((host) => {
if (!host.subNextduedate || host.subStatus !== "ACTIVE") return [];
const tage = tageBis(host.subNextduedate, kontext.jetzt);
if (tage < 0) return [];
// Immer nur die strengste erreichte Stufe melden.
const erreicht = stufen.filter((stufe) => tage <= stufe).sort((a, b) => a - b)[0];
if (erreicht === undefined) return [];
return [
{
fingerprint: `subscription-laeuft-ab:${host.id}:${erreicht}`,
regel: "subscription-laeuft-ab",
schwere: (erreicht <= 7 ? "GEFAHR" : erreicht <= 14 ? "WARNUNG" : "INFO") as AlertSchwere,
titel: `Subscription läuft in ${tage} Tag${tage === 1 ? "" : "en"} ab`,
beschreibung: `${host.hostname} · ${kundenHinweis(host.kundeName)} · ${formatDatum(host.subNextduedate)}`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
},
];
});
},
};
/** Eigene Titel, damit die Meldung ein ganzer Satz bleibt. */
const STATUS_TITEL: Record<SubscriptionStatus, string> = {
ACTIVE: "Subscription aktiv",
NOTFOUND: "Keine Subscription gefunden",
INVALID: "Subscription ungültig",
EXPIRED: "Subscription abgelaufen",
SUSPENDED: "Subscription ausgesetzt",
UNKNOWN: "Subscription-Status unbekannt",
};
const statusNichtAktiv: Regel = {
name: "status-nicht-aktiv",
beschreibung: "Subscription ist nicht aktiv",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.hosts
.filter((host) => host.subStatus !== "ACTIVE")
.map((host) => ({
fingerprint: `status-nicht-aktiv:${host.id}`,
regel: "status-nicht-aktiv",
schwere: (host.subStatus === "NOTFOUND" || host.subStatus === "UNKNOWN"
? "WARNUNG"
: "GEFAHR") as AlertSchwere,
titel: STATUS_TITEL[host.subStatus],
beschreibung: `${host.hostname} · ${kundenHinweis(host.kundeName)}${
host.subKey ? ` · ${host.subKey}` : ""
}`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
}));
},
};
const socketsAbweichung: Regel = {
name: "sockets-abweichung",
beschreibung: "Lizenzierte und physische Sockets weichen ab",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.hosts
.filter(
(host) =>
host.subLizenzierteSockets !== null &&
host.sockets !== null &&
host.subLizenzierteSockets !== host.sockets,
)
.map((host) => ({
fingerprint: `sockets-abweichung:${host.id}`,
regel: "sockets-abweichung",
schwere: "WARNUNG" as AlertSchwere,
titel: "Lizenzierte Sockets weichen ab",
beschreibung: `${host.hostname} · ${host.subLizenzierteSockets} lizenziert, ${host.sockets} physisch vorhanden`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
}));
},
};
const keineMeldung: Regel = {
name: "keine-meldung",
beschreibung: "Host hat sich lange nicht gemeldet",
standardSchwellwerte: { stunden: 48 },
pruefe(kontext) {
const stunden = schwellwert(kontext, "keine-meldung", "stunden", 48);
const grenze = new Date(kontext.jetzt.getTime() - stunden * 3_600_000);
return kontext.hosts
.filter((host) => host.letzterReportAm !== null && host.letzterReportAm < grenze)
.map((host) => ({
fingerprint: `keine-meldung:${host.id}`,
regel: "keine-meldung",
schwere: "WARNUNG" as AlertSchwere,
titel: `Host meldet sich seit über ${stunden} Stunden nicht`,
beschreibung: `${host.hostname} · ${kundenHinweis(host.kundeName)} · letzte Meldung ${formatDatum(
host.letzterReportAm,
)}`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
}));
},
};
const serverIdGeaendert: Regel = {
name: "serverid-geaendert",
beschreibung: "Server-ID hat sich geändert (Hardwaretausch oder Neuinstallation)",
standardSchwellwerte: { tage: 30 },
pruefe(kontext) {
const tage = schwellwert(kontext, "serverid-geaendert", "tage", 30);
const grenze = new Date(kontext.jetzt.getTime() - tage * TAG);
return kontext.hosts.flatMap((host) => {
if (host.serverIds.length < 2) return [];
const nachSortiert = [...host.serverIds].sort(
(a, b) => b.letzteMeldung.getTime() - a.letzteMeldung.getTime(),
);
const aktuell = nachSortiert[0];
const vorher = nachSortiert[1];
// Nur melden, solange der Wechsel frisch ist – ein alter Hardwaretausch ist erledigt.
if (aktuell.ersteMeldung < grenze) return [];
return [
{
fingerprint: `serverid-geaendert:${host.id}:${aktuell.serverId}`,
regel: "serverid-geaendert",
schwere: "GEFAHR" as AlertSchwere,
titel: "Server-ID hat sich geändert",
beschreibung: `${host.hostname} · neu ${aktuell.serverId}, vorher ${vorher.serverId} – der Key muss bei Proxmox neu zugewiesen werden`,
hostId: host.id,
kundeId: host.kundeId,
laufzeitId: null,
},
];
});
},
};
const keyMehrfach: Regel = {
name: "key-mehrfach-gemeldet",
beschreibung: "Derselbe Subscription-Key wird von mehreren Hosts gemeldet",
standardSchwellwerte: {},
pruefe(kontext) {
const proKey = new Map<string, HostDaten[]>();
for (const host of kontext.hosts) {
if (!host.subKey) continue;
proKey.set(host.subKey, [...(proKey.get(host.subKey) ?? []), host]);
}
return [...proKey.entries()]
.filter(([, hosts]) => hosts.length > 1)
.map(([key, hosts]) => ({
fingerprint: `key-mehrfach-gemeldet:${key}`,
regel: "key-mehrfach-gemeldet",
schwere: "GEFAHR" as AlertSchwere,
titel: "Key wird von mehreren Hosts gemeldet",
beschreibung: `${key} · ${hosts.map((h) => h.hostname).join(", ")}`,
hostId: hosts[0].id,
kundeId: hosts[0].kundeId,
laufzeitId: null,
}));
},
};
const hostOhneKunde: Regel = {
name: "host-ohne-kunde",
beschreibung: "Host ist keinem Kunden zugewiesen",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.hosts
.filter((host) => host.kundeId === null)
.map((host) => ({
fingerprint: `host-ohne-kunde:${host.id}`,
regel: "host-ohne-kunde",
schwere: "INFO" as AlertSchwere,
titel: "Host ist keinem Kunden zugewiesen",
beschreibung: `${host.hostname}${host.subKey ? ` · ${host.subKey}` : ""}`,
hostId: host.id,
kundeId: null,
laufzeitId: null,
}));
},
};
const proxmoxRechnungFaellig: Regel = {
name: "proxmox-rechnung-faellig",
beschreibung: "Proxmox-Rechnung ist unbezahlt und wird fällig",
standardSchwellwerte: { tage: 7 },
pruefe(kontext) {
const tage = schwellwert(kontext, "proxmox-rechnung-faellig", "tage", 7);
return kontext.laufzeiten.flatMap((laufzeit) => {
if (laufzeit.einkaufBezahlt !== false || !laufzeit.einkaufFaelligAm) return [];
const rest = tageBis(laufzeit.einkaufFaelligAm, kontext.jetzt);
if (rest > tage) return [];
return [
{
fingerprint: `proxmox-rechnung-faellig:${laufzeit.id}`,
regel: "proxmox-rechnung-faellig",
schwere: (rest < 0 ? "GEFAHR" : "WARNUNG") as AlertSchwere,
titel: rest < 0 ? "Proxmox-Rechnung überfällig" : "Proxmox-Rechnung wird fällig",
beschreibung: `${laufzeit.einkaufNummer ?? "ohne Nummer"} · ${formatEuro(
laufzeit.einkaufBetrag,
)} netto · fällig am ${formatDatum(laufzeit.einkaufFaelligAm)} · ${laufzeit.hostname}`,
hostId: laufzeit.hostId,
kundeId: laufzeit.kundeId,
laufzeitId: laufzeit.id,
},
];
});
},
};
const kundenrechnungFehlt: Regel = {
name: "kundenrechnung-fehlt",
beschreibung: "Einkauf ist erfasst, die Kundenrechnung fehlt noch",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.laufzeiten
.filter((laufzeit) => laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt)
.filter((laufzeit) => laufzeit.kundeId !== null)
.map((laufzeit) => ({
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
regel: "kundenrechnung-fehlt",
schwere: "WARNUNG" as AlertSchwere,
titel: "Kundenrechnung noch nicht erstellt",
beschreibung: `${laufzeit.hostname} · ${kundenHinweis(laufzeit.kundeName)} · Laufzeit ab ${formatDatum(
laufzeit.start,
)} · Einkauf ${laufzeit.einkaufNummer}`,
hostId: laufzeit.hostId,
kundeId: laufzeit.kundeId,
laufzeitId: laufzeit.id,
}));
},
};
const kundenrechnungUnbezahlt: Regel = {
name: "kundenrechnung-unbezahlt",
beschreibung: "Kundenrechnung ist erstellt, aber noch nicht bezahlt",
standardSchwellwerte: { tage: 30 },
pruefe(kontext) {
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
return kontext.laufzeiten.flatMap((laufzeit) => {
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
if (offenSeit < tage) return [];
return [
{
fingerprint: `kundenrechnung-unbezahlt:${laufzeit.id}`,
regel: "kundenrechnung-unbezahlt",
schwere: "WARNUNG" as AlertSchwere,
titel: `Kundenrechnung seit ${offenSeit} Tagen offen`,
beschreibung: `${laufzeit.verkaufNummer ?? "ohne Nummer"} · ${kundenHinweis(
laufzeit.kundeName,
)} · ${formatEuro(laufzeit.verkaufBetrag)} netto`,
hostId: laufzeit.hostId,
kundeId: laufzeit.kundeId,
laufzeitId: laufzeit.id,
},
];
});
},
};
export const REGELN: Regel[] = [
subscriptionLaeuftAb,
statusNichtAktiv,
socketsAbweichung,
keineMeldung,
serverIdGeaendert,
keyMehrfach,
hostOhneKunde,
proxmoxRechnungFaellig,
kundenrechnungFehlt,
kundenrechnungUnbezahlt,
];
/** Wendet alle aktiven Regeln an. */
export function alleRegelnPruefen(kontext: RegelKontext): AlertKandidat[] {
return REGELN.filter((regel) => kontext.einstellungen.get(regel.name)?.aktiv !== false).flatMap((regel) =>
regel.pruefe(kontext),
);
}
+164
View File
@@ -0,0 +1,164 @@
import { createHmac } from "node:crypto";
import nodemailer from "nodemailer";
import type { NotificationKanal } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
* an den sich Matrix, Zabbix oder Home Assistant anbinden lassen.
*/
export interface EmailKonfiguration {
host: string;
port: number;
/** true = SMTPS (Port 465), false = STARTTLS (Port 587, so macht es Microsoft 365). */
secure: boolean;
benutzer: string;
passwort: string;
absender: string;
empfaenger: string[];
}
export interface WebhookKonfiguration {
url: string;
/** Optionales Geheimnis für die Signatur im Header `X-ProxLedger-Signatur`. */
geheimnis?: string;
kopfzeilen?: Record<string, string>;
}
export interface Nachricht {
betreff: string;
text: string;
html: string;
/** Strukturierte Fassung für den Webhook. */
daten: Record<string, unknown>;
}
export class VersandFehler extends Error {}
/** Verschickt eine Nachricht per SMTP. */
export async function perEmailSenden(konfiguration: EmailKonfiguration, nachricht: Nachricht): Promise<void> {
if (konfiguration.empfaenger.length === 0) {
throw new VersandFehler("Kein Empfänger hinterlegt.");
}
const transport = nodemailer.createTransport({
host: konfiguration.host,
port: konfiguration.port,
secure: konfiguration.secure,
auth: konfiguration.benutzer ? { user: konfiguration.benutzer, pass: konfiguration.passwort } : undefined,
// Microsoft 365 verlangt STARTTLS auf Port 587; `requireTLS` verhindert unverschlüsselten Versand.
requireTLS: !konfiguration.secure,
});
try {
await transport.sendMail({
from: konfiguration.absender,
to: konfiguration.empfaenger.join(", "),
subject: nachricht.betreff,
text: nachricht.text,
html: nachricht.html,
});
} catch (fehler) {
throw new VersandFehler(fehler instanceof Error ? fehler.message : "SMTP-Versand fehlgeschlagen.");
} finally {
transport.close();
}
}
/** Verschickt eine Nachricht an einen Webhook; bei Geheimnis mit HMAC-Signatur. */
export async function perWebhookSenden(
konfiguration: WebhookKonfiguration,
nachricht: Nachricht,
): Promise<void> {
const koerper = JSON.stringify({
betreff: nachricht.betreff,
text: nachricht.text,
...nachricht.daten,
});
const kopfzeilen: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "ProxLedger",
...konfiguration.kopfzeilen,
};
if (konfiguration.geheimnis) {
kopfzeilen["X-ProxLedger-Signatur"] = `sha256=${createHmac("sha256", konfiguration.geheimnis)
.update(koerper)
.digest("hex")}`;
}
let antwort: Response;
try {
antwort = await fetch(konfiguration.url, { method: "POST", headers: kopfzeilen, body: koerper });
} catch (fehler) {
throw new VersandFehler(fehler instanceof Error ? fehler.message : "Webhook nicht erreichbar.");
}
if (!antwort.ok) {
throw new VersandFehler(`Webhook antwortet mit HTTP ${antwort.status}.`);
}
}
export interface KanalEintrag {
id: string;
typ: NotificationKanal;
name: string;
aktiv: boolean;
konfiguration: EmailKonfiguration | WebhookKonfiguration;
}
export async function aktiveKanaele(): Promise<KanalEintrag[]> {
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
return kanaele.map((kanal) => ({
id: kanal.id,
typ: kanal.typ,
name: kanal.name,
aktiv: kanal.aktiv,
konfiguration: kanal.konfiguration as unknown as EmailKonfiguration | WebhookKonfiguration,
}));
}
/** Verschickt über einen Kanal und schreibt das Ergebnis ins Versandprotokoll. */
export async function ueberKanalSenden(
kanal: KanalEintrag,
nachricht: Nachricht,
alertId: string | null = null,
): Promise<void> {
const empfaenger =
kanal.typ === "EMAIL"
? (kanal.konfiguration as EmailKonfiguration).empfaenger.join(", ")
: (kanal.konfiguration as WebhookKonfiguration).url;
try {
if (kanal.typ === "EMAIL") {
await perEmailSenden(kanal.konfiguration as EmailKonfiguration, nachricht);
} else {
await perWebhookSenden(kanal.konfiguration as WebhookKonfiguration, nachricht);
}
await prisma.benachrichtigung.create({
data: {
alertId,
kanal: kanal.typ,
empfaenger,
betreff: nachricht.betreff,
status: "GESENDET",
versendetAm: new Date(),
},
});
} catch (fehler) {
await prisma.benachrichtigung.create({
data: {
alertId,
kanal: kanal.typ,
empfaenger,
betreff: nachricht.betreff,
status: "FEHLGESCHLAGEN",
fehler: fehler instanceof Error ? fehler.message : String(fehler),
},
});
throw fehler;
}
}
+172
View File
@@ -0,0 +1,172 @@
import type { AlertSchwere } from "@/generated/prisma/enums";
import { formatDatum } from "@/lib/format";
import type { Nachricht } from "./kanaele";
/**
* Textbausteine für Benachrichtigungen. Bewusst schlichtes HTML mit Inline-Stilen –
* E-Mail-Programme ignorieren Stylesheets.
*/
export interface AlertNachrichtDaten {
id: string;
regel: string;
schwere: AlertSchwere;
titel: string;
beschreibung: string;
hostId: string | null;
}
const FARBE: Record<AlertSchwere, string> = {
GEFAHR: "#C82333",
WARNUNG: "#FFC107",
INFO: "#138496",
};
const SCHWERE_TEXT: Record<AlertSchwere, string> = {
GEFAHR: "Gefahr",
WARNUNG: "Warnung",
INFO: "Hinweis",
};
function rahmen(inhalt: string, appUrl: string): string {
return `<div style="font-family:'Source Sans 3',Arial,sans-serif;font-size:15px;color:#1F2D3D;max-width:640px">
<div style="border-bottom:3px solid #1E2A44;padding-bottom:8px;margin-bottom:16px">
<span style="font-size:20px;color:#1E2A44;font-weight:600">ProxLedger</span>
<span style="color:#6C757D"> · Proxmox Subscription Dashboard</span>
</div>
${inhalt}
<p style="color:#6C757D;font-size:13px;margin-top:24px">
<a href="${appUrl}" style="color:#007BFF">Dashboard öffnen</a>
</p>
</div>`;
}
/** Eine einzelne neue Warnung. */
export function alertNachricht(alert: AlertNachrichtDaten, appUrl: string): Nachricht {
const betreff = `[ProxLedger] ${SCHWERE_TEXT[alert.schwere]}: ${alert.titel}`;
const text = `${SCHWERE_TEXT[alert.schwere]}: ${alert.titel}\n${alert.beschreibung}\n\nRegel: ${alert.regel}\n${appUrl}/warnungen`;
const html = rahmen(
`<p style="margin:0 0 4px"><span style="display:inline-block;padding:2px 8px;border-radius:4px;background:${FARBE[alert.schwere]};color:${
alert.schwere === "WARNUNG" ? "#1F2D3D" : "#FFFFFF"
};font-size:13px">${SCHWERE_TEXT[alert.schwere]}</span></p>
<h2 style="margin:8px 0;font-size:18px">${alert.titel}</h2>
<p style="margin:0 0 8px">${alert.beschreibung}</p>
<p style="color:#6C757D;font-size:13px;margin:0">Regel: ${alert.regel}</p>`,
appUrl,
);
return {
betreff,
text,
html,
daten: {
art: "warnung",
alertId: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
},
};
}
export interface ZusammenfassungDaten {
zeitraum: "taeglich" | "woechentlich";
offeneAlerts: AlertNachrichtDaten[];
neueAlerts: AlertNachrichtDaten[];
baldAblaufend: { hostname: string; kunde: string; datum: Date; tage: number }[];
offeneRechnungspunkte: number;
hostsOhneMeldung: number;
}
/** Tägliche oder wöchentliche Zusammenfassung. */
export function zusammenfassungNachricht(daten: ZusammenfassungDaten, appUrl: string): Nachricht {
const titel = daten.zeitraum === "taeglich" ? "Tagesübersicht" : "Wochenübersicht";
const betreff = `[ProxLedger] ${titel}: ${daten.offeneAlerts.length} offene Warnungen`;
const zeilen = [
`${titel} vom ${formatDatum(new Date())}`,
"",
`Offene Warnungen: ${daten.offeneAlerts.length} (davon neu: ${daten.neueAlerts.length})`,
`Offene Rechnungspunkte: ${daten.offeneRechnungspunkte}`,
`Hosts ohne Meldung: ${daten.hostsOhneMeldung}`,
"",
"Bald ablaufende Subscriptions:",
...(daten.baldAblaufend.length > 0
? daten.baldAblaufend.map(
(e) => ` · ${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`,
)
: [" keine in den nächsten 30 Tagen"]),
"",
"Offene Warnungen:",
...(daten.offeneAlerts.length > 0
? daten.offeneAlerts.map((a) => ` · [${SCHWERE_TEXT[a.schwere]}] ${a.titel} – ${a.beschreibung}`)
: [" keine"]),
"",
`${appUrl}/warnungen`,
];
const liste = (eintraege: string[]) =>
eintraege.length > 0
? `<ul style="margin:4px 0 16px;padding-left:20px">${eintraege.map((e) => `<li>${e}</li>`).join("")}</ul>`
: `<p style="color:#6C757D;margin:4px 0 16px">keine</p>`;
const html = rahmen(
`<h2 style="margin:0 0 12px;font-size:18px">${titel}</h2>
<table style="border-collapse:collapse;margin-bottom:16px">
<tr><td style="padding:2px 16px 2px 0">Offene Warnungen</td><td><strong>${daten.offeneAlerts.length}</strong> (neu: ${daten.neueAlerts.length})</td></tr>
<tr><td style="padding:2px 16px 2px 0">Offene Rechnungspunkte</td><td><strong>${daten.offeneRechnungspunkte}</strong></td></tr>
<tr><td style="padding:2px 16px 2px 0">Hosts ohne Meldung</td><td><strong>${daten.hostsOhneMeldung}</strong></td></tr>
</table>
<h3 style="font-size:15px;margin:0">Bald ablaufende Subscriptions</h3>
${liste(
daten.baldAblaufend.map(
(e) => `${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`,
),
)}
<h3 style="font-size:15px;margin:0">Offene Warnungen</h3>
${liste(
daten.offeneAlerts.map(
(a) =>
`<span style="color:${FARBE[a.schwere]}">${SCHWERE_TEXT[a.schwere]}</span> – ${a.titel}: ${a.beschreibung}`,
),
)}`,
appUrl,
);
return {
betreff,
text: zeilen.join("\n"),
html,
daten: {
art: "zusammenfassung",
zeitraum: daten.zeitraum,
offeneAlerts: daten.offeneAlerts.length,
neueAlerts: daten.neueAlerts.length,
offeneRechnungspunkte: daten.offeneRechnungspunkte,
hostsOhneMeldung: daten.hostsOhneMeldung,
baldAblaufend: daten.baldAblaufend.map((e) => ({
hostname: e.hostname,
kunde: e.kunde,
datum: e.datum.toISOString().slice(0, 10),
tage: e.tage,
})),
},
};
}
/** Testnachricht für den Knopf „Test senden“. */
export function testNachricht(appUrl: string): Nachricht {
return {
betreff: "[ProxLedger] Testnachricht",
text: `Diese Testnachricht bestätigt, dass der Versand funktioniert.\n${appUrl}`,
html: rahmen(
`<h2 style="margin:0 0 8px;font-size:18px">Testnachricht</h2>
<p style="margin:0">Der Versand funktioniert.</p>`,
appUrl,
),
daten: { art: "test" },
};
}
+158
View File
@@ -0,0 +1,158 @@
import { prisma } from "@/lib/db";
import { kennzahlen } from "@/server/queries/hosts";
import { aktiveKanaele, ueberKanalSenden, type Nachricht } from "./kanaele";
import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten";
/**
* Versandlogik: Wer bekommt wann was?
* Grundregel aus dem Auftrag: keine doppelten Benachrichtigungen für denselben offenen Alert.
* Das stellt die Prüfung gegen das Versandprotokoll sicher – auch wenn eine Auswertung mehrfach läuft.
*/
function appUrl(): string {
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
}
/** Wurde zu diesem Alert schon erfolgreich benachrichtigt? */
async function bereitsGemeldet(alertId: string): Promise<boolean> {
const vorhanden = await prisma.benachrichtigung.findFirst({
where: { alertId, status: "GESENDET" },
});
return vorhanden !== null;
}
export interface VersandErgebnis {
gesendet: number;
uebersprungen: number;
fehlgeschlagen: number;
}
/** Verschickt Benachrichtigungen für neu geöffnete Warnungen. */
export async function neueAlertsMelden(fingerprints: string[]): Promise<VersandErgebnis> {
if (fingerprints.length === 0) return { gesendet: 0, uebersprungen: 0, fehlgeschlagen: 0 };
const kanaele = await aktiveKanaele();
if (kanaele.length === 0) return { gesendet: 0, uebersprungen: fingerprints.length, fehlgeschlagen: 0 };
const alerts = await prisma.alert.findMany({
where: { fingerprint: { in: fingerprints }, erledigtAm: null },
});
let gesendet = 0;
let uebersprungen = 0;
let fehlgeschlagen = 0;
for (const alert of alerts) {
if (await bereitsGemeldet(alert.id)) {
uebersprungen += 1;
continue;
}
const nachricht = alertNachricht(
{
id: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
},
appUrl(),
);
for (const kanal of kanaele) {
try {
await ueberKanalSenden(kanal, nachricht, alert.id);
gesendet += 1;
} catch (fehler) {
fehlgeschlagen += 1;
console.error(`Versand über ${kanal.name} fehlgeschlagen:`, fehler);
}
}
}
return { gesendet, uebersprungen, fehlgeschlagen };
}
/** Stellt die Zusammenfassung zusammen und verschickt sie. */
export async function zusammenfassungSenden(
zeitraum: "taeglich" | "woechentlich",
jetzt: Date = new Date(),
): Promise<VersandErgebnis> {
const kanaele = await aktiveKanaele();
if (kanaele.length === 0) return { gesendet: 0, uebersprungen: 1, fehlgeschlagen: 0 };
const seit = new Date(jetzt.getTime() - (zeitraum === "taeglich" ? 1 : 7) * 86_400_000);
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
const [offene, neue, ablaufend, zahlen] = await Promise.all([
prisma.alert.findMany({ where: { erledigtAm: null }, orderBy: { schwere: "asc" } }),
prisma.alert.findMany({ where: { erledigtAm: null, geoeffnetAm: { gte: seit } } }),
prisma.host.findMany({
where: { subNextduedate: { gte: jetzt, lte: in30Tagen } },
orderBy: { subNextduedate: "asc" },
select: { hostname: true, subNextduedate: true, kunde: { select: { name: true } } },
}),
kennzahlen(jetzt),
]);
const alsDaten = (alert: (typeof offene)[number]): AlertNachrichtDaten => ({
id: alert.id,
regel: alert.regel,
schwere: alert.schwere,
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
});
const nachricht = zusammenfassungNachricht(
{
zeitraum,
offeneAlerts: offene.map(alsDaten),
neueAlerts: neue.map(alsDaten),
baldAblaufend: ablaufend.map((host) => ({
hostname: host.hostname,
kunde: host.kunde?.name ?? "Nicht zugeordnet",
datum: host.subNextduedate!,
tage: Math.round((host.subNextduedate!.getTime() - jetzt.getTime()) / 86_400_000),
})),
offeneRechnungspunkte: zahlen.offeneRechnungspunkte,
hostsOhneMeldung: zahlen.ohneMeldung,
},
appUrl(),
);
let gesendet = 0;
let fehlgeschlagen = 0;
for (const kanal of kanaele) {
try {
await ueberKanalSenden(kanal, nachricht, null);
gesendet += 1;
} catch {
fehlgeschlagen += 1;
}
}
return { gesendet, uebersprungen: 0, fehlgeschlagen };
}
/** Verschickt eine Nachricht über genau einen Kanal – für „Test senden“. */
export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachricht): Promise<void> {
const kanaele = await aktiveKanaele();
const kanal =
kanaele.find((eintrag) => eintrag.id === kanalId) ??
(await (async () => {
const eintrag = await prisma.benachrichtigungskanal.findUnique({ where: { id: kanalId } });
if (!eintrag) return undefined;
return {
id: eintrag.id,
typ: eintrag.typ,
name: eintrag.name,
aktiv: eintrag.aktiv,
konfiguration: eintrag.konfiguration as never,
};
})());
if (!kanal) throw new Error("Kanal nicht gefunden.");
await ueberKanalSenden(kanal, nachricht, null);
}
+168
View File
@@ -0,0 +1,168 @@
import { Document, Page, Path, StyleSheet, Svg, Text, View, renderToBuffer } from "@react-pdf/renderer";
import type { DocumentProps } from "@react-pdf/renderer";
import type { ReactElement } from "react";
/**
* Gemeinsames Aussehen aller PDF-Ausgaben: einfarbiges Logo, Titel, Datum, Seitenzahl.
* Gerendert wird serverseitig ohne Browser (@react-pdf/renderer).
*/
const NAVY = "#1E2A44";
const stile = StyleSheet.create({
seite: { paddingTop: 40, paddingBottom: 48, paddingHorizontal: 40, fontSize: 9, color: "#1F2D3D" },
kopf: { flexDirection: "row", alignItems: "center", marginBottom: 18 },
kopfText: { marginLeft: 10 },
marke: { fontSize: 14, color: NAVY },
titel: { fontSize: 16, marginBottom: 2 },
untertitel: { fontSize: 9, color: "#6C757D" },
tabellenKopf: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: NAVY,
paddingBottom: 4,
marginBottom: 2,
fontSize: 8,
color: NAVY,
},
zeile: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: "#DEE2E6",
paddingVertical: 3,
},
summenZeile: { flexDirection: "row", borderTopWidth: 1, borderTopColor: NAVY, paddingTop: 4, marginTop: 2 },
fuss: {
position: "absolute",
bottom: 24,
left: 40,
right: 40,
flexDirection: "row",
justifyContent: "space-between",
fontSize: 7,
color: "#6C757D",
},
abschnitt: { marginTop: 14, marginBottom: 6, fontSize: 11, color: NAVY },
});
function Logo() {
return (
<Svg viewBox="0 0 48 48" style={{ width: 28, height: 28 }}>
<Path
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z"
fill={NAVY}
fillRule="evenodd"
/>
<Path d="M22 36h20M22 43h14" stroke={NAVY} strokeWidth={3.5} strokeLinecap="round" />
</Svg>
);
}
export interface Spalte<T> {
titel: string;
breite: number;
wert: (zeile: T) => string;
rechts?: boolean;
}
/** Etwas Luft zwischen den Spalten, sonst stoßen rechtsbündige Zahlen an den nächsten Text. */
const zelle = (breite: number, rechts?: boolean) => ({
width: `${breite}%`,
textAlign: (rechts ? "right" : "left") as "right" | "left",
paddingRight: 6,
});
export function Tabelle<T>({
spalten,
zeilen,
summe,
}: {
spalten: Spalte<T>[];
zeilen: T[];
summe?: string[];
}) {
return (
<View>
<View style={stile.tabellenKopf} fixed>
{spalten.map((spalte) => (
<Text key={spalte.titel} style={zelle(spalte.breite, spalte.rechts)}>
{spalte.titel}
</Text>
))}
</View>
{zeilen.map((zeile, index) => (
<View key={index} style={stile.zeile} wrap={false}>
{spalten.map((spalte) => (
<Text key={spalte.titel} style={zelle(spalte.breite, spalte.rechts)}>
{spalte.wert(zeile)}
</Text>
))}
</View>
))}
{summe ? (
<View style={stile.summenZeile}>
{summe.map((wert, index) => (
<Text key={index} style={zelle(spalten[index]?.breite ?? 10, spalten[index]?.rechts)}>
{wert}
</Text>
))}
</View>
) : null}
</View>
);
}
export function Abschnitt({ titel }: { titel: string }) {
return <Text style={stile.abschnitt}>{titel}</Text>;
}
export function Bericht({
titel,
untertitel,
children,
quer = false,
}: {
titel: string;
untertitel?: string;
children: React.ReactNode;
quer?: boolean;
}) {
const erstellt = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
dateStyle: "long",
}).format(new Date());
return (
<Document title={titel} author="ProxLedger">
<Page size="A4" orientation={quer ? "landscape" : "portrait"} style={stile.seite}>
<View style={stile.kopf} fixed>
<Logo />
<View style={stile.kopfText}>
<Text style={stile.marke}>ProxLedger</Text>
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</Text>
</View>
</View>
<Text style={stile.titel}>{titel}</Text>
{untertitel ? <Text style={stile.untertitel}>{untertitel}</Text> : null}
<View style={{ marginTop: 12 }}>{children}</View>
<View style={stile.fuss} fixed>
<Text>Erstellt am {erstellt}</Text>
<Text render={({ pageNumber, totalPages }) => `Seite ${pageNumber} von ${totalPages}`} />
</View>
</Page>
</Document>
);
}
export async function pdfAntwort(element: ReactElement<DocumentProps>, dateiname: string): Promise<Response> {
const puffer = await renderToBuffer(element);
return new Response(new Uint8Array(puffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${dateiname}"`,
"Cache-Control": "no-store",
},
});
}
+17 -3
View File
@@ -66,7 +66,19 @@ export async function offeneAufgaben() {
}, },
}); });
const aufgaben = []; const aufgaben: {
id: string;
titel: string;
detail: string;
betrag: number | null;
faelligAm: Date | null;
dringlichkeit: "gefahr" | "warnung";
badgeText: string;
href: string;
start: Date;
ende: Date;
aktion: { laufzeitId: string; hostId: string; feld: "einkaufBezahlt" | "verkaufErstellt" };
}[] = [];
for (const laufzeit of laufzeiten) { for (const laufzeit of laufzeiten) {
const basis = `${laufzeit.host.hostname} · ${laufzeit.host.kunde?.name ?? "Nicht zugeordnet"}`; const basis = `${laufzeit.host.hostname} · ${laufzeit.host.kunde?.name ?? "Nicht zugeordnet"}`;
const einkauf = laufzeit.proxmoxPosition?.rechnung; const einkauf = laufzeit.proxmoxPosition?.rechnung;
@@ -80,9 +92,10 @@ export async function offeneAufgaben() {
faelligAm: einkauf.faelligAm, faelligAm: einkauf.faelligAm,
dringlichkeit: "gefahr" as const, dringlichkeit: "gefahr" as const,
badgeText: "Einkauf", badgeText: "Einkauf",
href: `/hosts/${laufzeit.hostId}`, href: `/rechnungen/${laufzeit.id}`,
start: laufzeit.start, start: laufzeit.start,
ende: laufzeit.ende, ende: laufzeit.ende,
aktion: { laufzeitId: laufzeit.id, hostId: laufzeit.hostId, feld: "einkaufBezahlt" as const },
}); });
} }
@@ -95,9 +108,10 @@ export async function offeneAufgaben() {
faelligAm: null, faelligAm: null,
dringlichkeit: "warnung" as const, dringlichkeit: "warnung" as const,
badgeText: "Verkauf", badgeText: "Verkauf",
href: "/rechnungen", href: `/rechnungen/${laufzeit.id}`,
start: laufzeit.start, start: laufzeit.start,
ende: laufzeit.ende, ende: laufzeit.ende,
aktion: { laufzeitId: laufzeit.id, hostId: laufzeit.hostId, feld: "verkaufErstellt" as const },
}); });
} }
} }
+73
View File
@@ -0,0 +1,73 @@
import { prisma } from "@/lib/db";
/** Hosts mit Ablaufdatum, gruppiert nach Kunde – Grundlage für Jahresbahn und Ablauf-Kalender. */
export async function ablaufDaten(jetzt: Date = new Date()) {
const hosts = await prisma.host.findMany({
where: { subNextduedate: { not: null } },
orderBy: [{ subNextduedate: "asc" }],
select: {
id: true,
hostname: true,
subKey: true,
subNextduedate: true,
subStatus: true,
kunde: { select: { id: true, name: true } },
laufzeiten: {
where: { status: "BESTAETIGT" },
orderBy: { start: "desc" },
take: 2,
select: {
id: true,
start: true,
proxmoxPosition: { select: { rechnung: { select: { bezahlt: true } } } },
kundenPosition: { select: { rechnung: { select: { erstellt: true } } } },
},
},
},
});
const eintraege = hosts.map((host) => {
const ablauf = host.subNextduedate!;
const verlaengerung = host.laufzeiten.find((l) => l.start.getTime() === ablauf.getTime());
return {
hostId: host.id,
hostname: host.hostname,
key: host.subKey,
ablauf,
status: host.subStatus,
kunde: host.kunde,
laufzeitId: verlaengerung?.id ?? null,
einkaufBezahlt: verlaengerung?.proxmoxPosition?.rechnung.bezahlt ?? null,
verkaufErstellt: verlaengerung?.kundenPosition?.rechnung.erstellt ?? null,
tage: Math.round((ablauf.getTime() - jetzt.getTime()) / 86_400_000),
};
});
// Nach Kunde gruppieren; Hosts ohne Kunde kommen ans Ende.
const gruppen = new Map<string, { name: string; kundeId: string | null; eintraege: typeof eintraege }>();
for (const eintrag of eintraege) {
const schluessel = eintrag.kunde?.id ?? "ohne";
const gruppe = gruppen.get(schluessel) ?? {
name: eintrag.kunde?.name ?? "Nicht zugeordnet",
kundeId: eintrag.kunde?.id ?? null,
eintraege: [],
};
gruppe.eintraege.push(eintrag);
gruppen.set(schluessel, gruppe);
}
const sortiert = [...gruppen.values()].sort((a, b) => {
if (a.kundeId === null) return 1;
if (b.kundeId === null) return -1;
return a.name.localeCompare(b.name, "de");
});
return {
gruppen: sortiert,
naechste90Tage: eintraege.filter((e) => e.tage >= 0 && e.tage <= 90),
abgelaufen: eintraege.filter((e) => e.tage < 0),
};
}
export type AblaufEintrag = Awaited<ReturnType<typeof ablaufDaten>>["naechste90Tage"][number];
+152
View File
@@ -0,0 +1,152 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
export interface RechnungsFilter {
status?: "offen" | "erledigt" | "alle" | "vorschlaege";
kunde?: string;
jahr?: string;
q?: string;
}
function bedingung(filter: RechnungsFilter): Prisma.LaufzeitWhereInput {
const und: Prisma.LaufzeitWhereInput[] = [];
if (filter.status === "vorschlaege") {
und.push({ status: "VORSCHLAG" });
} else {
und.push({ status: "BESTAETIGT" });
}
if (filter.status === "offen") {
und.push({
OR: [
{ proxmoxPosition: null },
{ proxmoxPosition: { rechnung: { bezahlt: false } } },
{ kundenPosition: null },
{ kundenPosition: { rechnung: { erstellt: false } } },
],
});
}
if (filter.status === "erledigt") {
und.push({ proxmoxPosition: { rechnung: { bezahlt: true } } });
und.push({ kundenPosition: { rechnung: { erstellt: true } } });
}
if (filter.kunde) und.push({ host: { kundeId: filter.kunde } });
if (filter.jahr) {
const jahr = Number(filter.jahr);
und.push({
start: { gte: new Date(Date.UTC(jahr, 0, 1)), lt: new Date(Date.UTC(jahr + 1, 0, 1)) },
});
}
if (filter.q?.trim()) {
const suche = filter.q.trim();
und.push({
OR: [
{ key: { contains: suche, mode: "insensitive" } },
{ host: { hostname: { contains: suche, mode: "insensitive" } } },
{ proxmoxPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } },
{ kundenPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } },
],
});
}
return { AND: und };
}
export async function rechnungsListe(filter: RechnungsFilter) {
const laufzeiten = await prisma.laufzeit.findMany({
where: bedingung(filter),
orderBy: [{ start: "desc" }],
include: {
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } },
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
});
const zeilen = laufzeiten.map((laufzeit) => {
const einkauf = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
return {
id: laufzeit.id,
hostId: laufzeit.host.id,
hostname: laufzeit.host.hostname,
kunde: laufzeit.host.kunde,
key: laufzeit.key,
start: laufzeit.start,
ende: laufzeit.ende,
vorschlag: laufzeit.status === "VORSCHLAG",
einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null,
einkaufBetrag: einkauf,
einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null,
verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null,
verkaufBetrag: verkauf,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
marge: einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
};
});
const summen = zeilen.reduce(
(summe, zeile) => ({
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0),
}),
{ einkauf: 0, verkauf: 0 },
);
return {
zeilen,
summen: { ...summen, marge: summen.verkauf - summen.einkauf },
};
}
export type RechnungsZeile = Awaited<ReturnType<typeof rechnungsListe>>["zeilen"][number];
/** Jahre, für die Laufzeiten vorliegen – für den Zeitraum-Filter. */
export async function laufzeitJahre(): Promise<string[]> {
const laufzeiten = await prisma.laufzeit.findMany({ select: { start: true } });
return [...new Set(laufzeiten.map((l) => String(l.start.getUTCFullYear())))].sort((a, b) =>
b.localeCompare(a),
);
}
export async function laufzeitDetail(id: string) {
return prisma.laufzeit.findUnique({
where: { id },
include: {
host: {
select: {
id: true,
hostname: true,
produkt: true,
kunde: { select: { id: true, name: true } },
},
},
proxmoxPosition: {
include: {
rechnung: {
include: { anhang: true, positionen: { include: { laufzeit: { include: { host: true } } } } },
},
},
},
kundenPosition: {
include: {
rechnung: {
include: { anhang: true, positionen: { include: { laufzeit: { include: { host: true } } } } },
},
},
},
},
});
}
export async function anzahlVorschlaege() {
return prisma.laufzeit.count({ where: { status: "VORSCHLAG" } });
}
+312
View File
@@ -0,0 +1,312 @@
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
export class RechnungsFehler extends Error {}
export interface ProxmoxRechnungEingabe {
nummer: string;
datum: Date;
faelligAm: Date | null;
nettoBetrag: number;
ustSatz?: number;
rabatt?: number | null;
zahlungsart?: string | null;
bezahlt: boolean;
bezahltAm?: Date | null;
anhangId?: string | null;
notizen?: string | null;
}
export interface KundenrechnungEingabe {
erstellt: boolean;
nummer?: string | null;
datum?: Date | null;
nettoBetrag?: number | null;
ustSatz?: number;
bezahlt: boolean;
bezahltAm?: Date | null;
anhangId?: string | null;
notizen?: string | null;
}
/** Bestätigt einen vom Agent vorgeschlagenen Lizenzzeitraum. */
export async function laufzeitBestaetigen(id: string) {
const laufzeit = await prisma.laufzeit.update({
where: { id },
data: { status: "BESTAETIGT" },
});
await protokolliere("laufzeit.bestaetigt", "Laufzeit", id, {
start: laufzeit.start,
ende: laufzeit.ende,
});
return laufzeit;
}
/** Verwirft einen Vorschlag; bereits bestätigte Laufzeiten bleiben unangetastet. */
export async function laufzeitVerwerfen(id: string) {
const laufzeit = await prisma.laufzeit.findUnique({ where: { id } });
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (laufzeit.status !== "VORSCHLAG") {
throw new RechnungsFehler("Nur Vorschläge können verworfen werden.");
}
await prisma.laufzeit.delete({ where: { id } });
await protokolliere("laufzeit.verworfen", "Laufzeit", id, { start: laufzeit.start });
}
/**
* Erfasst die Proxmox-Rechnung zu einer Laufzeit.
* Gibt es die Rechnungsnummer schon, wird eine weitere Position angehängt – so entsteht aus mehreren
* Laufzeiten eine Sammelrechnung, ohne dass die Nummer doppelt vergeben wird.
*/
export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: ProxmoxRechnungEingabe) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { proxmoxPosition: true, host: { select: { hostname: true } } },
});
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
const nummer = eingabe.nummer.trim();
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
return prisma.$transaction(async (tx) => {
const vorhanden = await tx.proxmoxRechnung.findUnique({ where: { nummer } });
const rechnung = vorhanden
? await tx.proxmoxRechnung.update({
where: { id: vorhanden.id },
data: {
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
ustSatz: eingabe.ustSatz ?? 19,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? vorhanden.anhangId,
notizen: eingabe.notizen ?? vorhanden.notizen,
},
})
: await tx.proxmoxRechnung.create({
data: {
nummer,
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
nettoBetrag: eingabe.nettoBetrag,
ustSatz: eingabe.ustSatz ?? 19,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? null,
notizen: eingabe.notizen ?? null,
},
});
const position = laufzeit.proxmoxPosition
? await tx.proxmoxRechnungPosition.update({
where: { id: laufzeit.proxmoxPosition.id },
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
})
: await tx.proxmoxRechnungPosition.create({
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
});
await tx.laufzeit.update({
where: { id: laufzeitId },
data: { proxmoxPositionId: position.id, status: "BESTAETIGT" },
});
// Der Rechnungsbetrag ist immer die Summe der Positionen.
const positionen = await tx.proxmoxRechnungPosition.findMany({ where: { rechnungId: rechnung.id } });
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0);
await tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
nummer,
laufzeitId,
nettoBetrag: eingabe.nettoBetrag,
positionen: positionen.length,
});
return { rechnungId: rechnung.id, positionId: position.id, sammelrechnung: positionen.length > 1 };
});
}
/** Erfasst die Kundenrechnung zu einer Laufzeit; gleiche Nummer bedeutet auch hier Sammelrechnung. */
export async function kundenrechnungErfassen(laufzeitId: string, eingabe: KundenrechnungEingabe) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { kundenPosition: true, host: { select: { hostname: true, kundeId: true } } },
});
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
const kundeId = laufzeit.host.kundeId;
if (!kundeId) {
throw new RechnungsFehler("Der Host ist keinem Kunden zugewiesen – ohne Kunde keine Kundenrechnung.");
}
const nummer = eingabe.nummer?.trim() || null;
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
const betrag = eingabe.nettoBetrag ?? 0;
return prisma.$transaction(async (tx) => {
const vorhanden = nummer ? await tx.kundenrechnung.findUnique({ where: { nummer } }) : null;
if (vorhanden && vorhanden.kundeId !== kundeId) {
throw new RechnungsFehler("Diese Rechnungsnummer gehört bereits zu einem anderen Kunden.");
}
const daten = {
erstellt: eingabe.erstellt,
nummer,
datum: eingabe.datum ?? null,
ustSatz: eingabe.ustSatz ?? 19,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? null,
notizen: eingabe.notizen ?? null,
};
// Welche Rechnung ist gemeint? Zuerst die über die Nummer gefundene, sonst die bisher verknüpfte.
const zielRechnungId = vorhanden?.id ?? laufzeit.kundenPosition?.rechnungId ?? null;
const rechnung = zielRechnungId
? await tx.kundenrechnung.update({ where: { id: zielRechnungId }, data: daten })
: await tx.kundenrechnung.create({
data: { ...daten, kundeId, nettoBetrag: betrag },
});
const position = laufzeit.kundenPosition
? await tx.kundenrechnungPosition.update({
where: { id: laufzeit.kundenPosition.id },
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: betrag },
})
: await tx.kundenrechnungPosition.create({
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: betrag },
});
await tx.laufzeit.update({ where: { id: laufzeitId }, data: { kundenPositionId: position.id } });
const positionen = await tx.kundenrechnungPosition.findMany({ where: { rechnungId: rechnung.id } });
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0);
await tx.kundenrechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
await protokolliere("kundenrechnung.erfasst", "Kundenrechnung", rechnung.id, {
nummer,
laufzeitId,
nettoBetrag: betrag,
positionen: positionen.length,
});
return { rechnungId: rechnung.id, positionId: position.id, sammelrechnung: positionen.length > 1 };
});
}
/** Häkchen „Proxmox bezahlt“ direkt aus der Tabelle. */
export async function einkaufBezahltSetzen(laufzeitId: string, bezahlt: boolean) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { proxmoxPosition: true },
});
if (!laufzeit?.proxmoxPosition) {
throw new RechnungsFehler("Für diese Laufzeit ist noch keine Proxmox-Rechnung erfasst.");
}
await prisma.proxmoxRechnung.update({
where: { id: laufzeit.proxmoxPosition.rechnungId },
data: { bezahlt, bezahltAm: bezahlt ? new Date() : null },
});
await protokolliere(
"proxmox_rechnung.bezahlt_gesetzt",
"ProxmoxRechnung",
laufzeit.proxmoxPosition.rechnungId,
{
bezahlt,
laufzeitId,
},
);
}
/**
* Häkchen „Kundenrechnung erstellt“ direkt aus der Tabelle.
* Fehlt die Rechnung noch, entsteht eine ohne Nummer – sie lässt sich später vervollständigen.
*/
export async function verkaufErstelltSetzen(laufzeitId: string, erstellt: boolean) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { kundenPosition: true, host: { select: { hostname: true, kundeId: true } } },
});
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (laufzeit.kundenPosition) {
await prisma.kundenrechnung.update({
where: { id: laufzeit.kundenPosition.rechnungId },
data: { erstellt },
});
await protokolliere(
"kundenrechnung.erstellt_gesetzt",
"Kundenrechnung",
laufzeit.kundenPosition.rechnungId,
{
erstellt,
laufzeitId,
},
);
return;
}
if (!erstellt) return;
await kundenrechnungErfassen(laufzeitId, { erstellt: true, bezahlt: false, nettoBetrag: 0 });
}
/** Häkchen „Kundenrechnung bezahlt“. */
export async function verkaufBezahltSetzen(laufzeitId: string, bezahlt: boolean) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { kundenPosition: true },
});
if (!laufzeit?.kundenPosition) {
throw new RechnungsFehler("Für diese Laufzeit ist noch keine Kundenrechnung erfasst.");
}
await prisma.kundenrechnung.update({
where: { id: laufzeit.kundenPosition.rechnungId },
data: { bezahlt, bezahltAm: bezahlt ? new Date() : null },
});
await protokolliere(
"kundenrechnung.bezahlt_gesetzt",
"Kundenrechnung",
laufzeit.kundenPosition.rechnungId,
{
bezahlt,
laufzeitId,
},
);
}
/** Legt eine Laufzeit von Hand an – etwa für Käufe, die der Agent noch nicht gemeldet hat. */
export async function laufzeitAnlegen(daten: {
hostId: string;
key: string;
start: Date;
ende: Date;
notizen?: string | null;
}) {
const vorhanden = await prisma.laufzeit.findUnique({
where: { hostId_start: { hostId: daten.hostId, start: daten.start } },
});
if (vorhanden) {
throw new RechnungsFehler("Für dieses Startdatum gibt es bereits eine Laufzeit.");
}
if (daten.ende <= daten.start) {
throw new RechnungsFehler("Das Ende muss nach dem Beginn liegen.");
}
const laufzeit = await prisma.laufzeit.create({
data: { ...daten, status: "BESTAETIGT" },
});
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
return laufzeit;
}
+86
View File
@@ -0,0 +1,86 @@
import { Cron } from "croner";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
/**
* Hintergrundprozess: wertet die Warnregeln regelmäßig aus, verschickt Benachrichtigungen für
* neue Warnungen und schickt die Zusammenfassung. Läuft als eigener Dienst neben der Web-Anwendung (#014).
*
* npm run worker
*/
const ZEITZONE = "Europe/Berlin";
const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde
const ZUSAMMENFASSUNG_ZEITPLAN = process.env.ZUSAMMENFASSUNG_CRON ?? "0 7 * * *"; // täglich 07:00 Uhr
function protokoll(nachricht: string) {
console.log(`[${new Date().toISOString()}] ${nachricht}`);
}
async function durchlauf() {
try {
const ergebnis = await alertsAbgleichen();
protokoll(
`Warnregeln geprüft: ${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert.`,
);
if (ergebnis.geoeffnet.length > 0) {
const versand = await neueAlertsMelden(ergebnis.geoeffnet.map((alert) => alert.fingerprint));
protokoll(
`Benachrichtigungen: ${versand.gesendet} gesendet, ${versand.uebersprungen} übersprungen, ${versand.fehlgeschlagen} fehlgeschlagen.`,
);
}
} catch (fehler) {
console.error("Auswertung fehlgeschlagen:", fehler);
}
}
/** Zeitraum der Zusammenfassung aus den Einstellungen (täglich, wöchentlich oder aus). */
async function zusammenfassungEinstellung(): Promise<"taeglich" | "woechentlich" | "aus"> {
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: "zusammenfassung" } });
const wert = (eintrag?.wert as { zeitraum?: string } | null)?.zeitraum;
return wert === "taeglich" || wert === "woechentlich" ? wert : "aus";
}
async function zusammenfassung() {
try {
const zeitraum = await zusammenfassungEinstellung();
if (zeitraum === "aus") return;
// Wöchentlich nur montags verschicken.
if (zeitraum === "woechentlich" && new Date().getDay() !== 1) return;
const ergebnis = await zusammenfassungSenden(zeitraum);
protokoll(
`Zusammenfassung (${zeitraum}): ${ergebnis.gesendet} gesendet, ${ergebnis.fehlgeschlagen} fehlgeschlagen.`,
);
} catch (fehler) {
console.error("Zusammenfassung fehlgeschlagen:", fehler);
}
}
async function start() {
await warnregelnSicherstellen();
protokoll(
`Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}" (${ZEITZONE}).`,
);
// Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird.
await durchlauf();
const aufgaben = [
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
];
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
protokoll("Worker wird beendet.");
for (const aufgabe of aufgaben) aufgabe.stop();
process.exit(0);
});
}
}
void start();
+134
View File
@@ -0,0 +1,134 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const JETZT = new Date("2026-09-17T12:00:00Z");
async function hostMitAblauf(tage: number) {
return testPrisma.host.create({
data: {
agentId: "agent-1",
hostname: "pve-01",
produkt: "PVE",
subStatus: "ACTIVE",
subKey: "pve1c-aaaa",
subNextduedate: new Date(JETZT.getTime() + tage * 86_400_000),
letzterReportAm: JETZT,
},
});
}
describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
beforeEach(async () => {
await datenbankLeeren();
await warnregelnSicherstellen();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(10);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
});
it("öffnet eine Warnung und meldet sie nicht doppelt", async () => {
await hostMitAblauf(5);
const erster = await alertsAbgleichen(JETZT);
expect(erster.geoeffnet.map((a) => a.regel)).toContain("subscription-laeuft-ab");
const zweiter = await alertsAbgleichen(JETZT);
expect(zweiter.geoeffnet).toHaveLength(0);
const offene = await testPrisma.alert.findMany({ where: { erledigtAm: null } });
expect(offene.filter((a) => a.regel === "subscription-laeuft-ab")).toHaveLength(1);
});
it("schließt eine Warnung, sobald die Ursache behoben ist", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
// Verlängert: neues Ablaufdatum weit in der Zukunft.
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 400 * 86_400_000) },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.erledigt).toContain(`subscription-laeuft-ab:${host.id}:7`);
const alert = await testPrisma.alert.findFirstOrThrow({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
expect(alert.erledigtAm).not.toBeNull();
});
it("öffnet einen erledigten Fall erneut, wenn er wieder auftritt", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 400 * 86_400_000) },
});
await alertsAbgleichen(JETZT);
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 5 * 86_400_000) },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(
`subscription-laeuft-ab:${host.id}:7`,
);
const alerts = await testPrisma.alert.findMany({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
// Derselbe Eintrag wird wiederverwendet, es entsteht kein zweiter.
expect(alerts).toHaveLength(1);
expect(alerts[0].erledigtAm).toBeNull();
});
it("führt die Beschreibung nach, wenn sich der Fall ändert", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
const spaeter = new Date(JETZT.getTime() + 2 * 86_400_000);
await alertsAbgleichen(spaeter);
const alert = await testPrisma.alert.findFirstOrThrow({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
expect(alert.titel).toBe("Subscription läuft in 3 Tagen ab");
});
it("steigt von der 30- auf die 7-Tage-Stufe", async () => {
const host = await hostMitAblauf(25);
const erster = await alertsAbgleichen(JETZT);
expect(erster.geoeffnet.some((a) => a.fingerprint.endsWith(":30"))).toBe(true);
const spaeter = new Date(JETZT.getTime() + 20 * 86_400_000);
const zweiter = await alertsAbgleichen(spaeter);
expect(zweiter.geoeffnet.some((a) => a.fingerprint.endsWith(":7"))).toBe(true);
expect(zweiter.erledigt).toContain(`subscription-laeuft-ab:${host.id}:30`);
});
it("beachtet abgeschaltete Regeln", async () => {
await testPrisma.host.create({
data: { agentId: "agent-2", hostname: "pve-ohne-kunde", produkt: "PVE", letzterReportAm: JETZT },
});
await testPrisma.warnregelEinstellung.update({
where: { regel: "host-ohne-kunde" },
data: { aktiv: false },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.some((a) => a.regel === "host-ohne-kunde")).toBe(false);
});
});
+234
View File
@@ -0,0 +1,234 @@
import { createHmac } from "node:crypto";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const APP_URL = "https://proxledger.example.de";
describe("Nachrichten", () => {
it("baut eine Warnungs-Nachricht mit Betreff, Text und Daten", () => {
const nachricht = alertNachricht(
{
id: "a1",
regel: "subscription-laeuft-ab",
schwere: "GEFAHR",
titel: "Subscription läuft in 7 Tagen ab",
beschreibung: "pve-01 · Brandl GmbH",
hostId: "h1",
},
APP_URL,
);
expect(nachricht.betreff).toBe("[ProxLedger] Gefahr: Subscription läuft in 7 Tagen ab");
expect(nachricht.text).toContain("pve-01 · Brandl GmbH");
expect(nachricht.text).toContain(`${APP_URL}/warnungen`);
expect(nachricht.html).toContain("ProxLedger");
expect(nachricht.daten).toMatchObject({ art: "warnung", alertId: "a1", schwere: "GEFAHR" });
});
it("baut eine Zusammenfassung mit Kennzahlen", () => {
const nachricht = zusammenfassungNachricht(
{
zeitraum: "taeglich",
offeneAlerts: [
{
id: "a1",
regel: "keine-meldung",
schwere: "WARNUNG",
titel: "Host meldet sich nicht",
beschreibung: "pve-02",
hostId: "h2",
},
],
neueAlerts: [],
baldAblaufend: [
{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 },
],
offeneRechnungspunkte: 3,
hostsOhneMeldung: 1,
},
APP_URL,
);
expect(nachricht.betreff).toBe("[ProxLedger] Tagesübersicht: 1 offene Warnungen");
expect(nachricht.text).toContain("Offene Rechnungspunkte: 3");
expect(nachricht.text).toContain("pve-01 (Brandl GmbH) – 24.09.2026, in 7 Tagen");
expect(nachricht.daten).toMatchObject({ art: "zusammenfassung", zeitraum: "taeglich" });
});
});
describe("Webhook", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("schickt JSON und signiert es mit dem Geheimnis", async () => {
const aufrufe: { url: string; optionen: RequestInit }[] = [];
vi.stubGlobal("fetch", async (url: string, optionen: RequestInit) => {
aufrufe.push({ url, optionen });
return new Response("ok", { status: 200 });
});
const nachricht = alertNachricht(
{
id: "a1",
regel: "status-nicht-aktiv",
schwere: "GEFAHR",
titel: "Subscription abgelaufen",
beschreibung: "pve-01",
hostId: "h1",
},
APP_URL,
);
await perWebhookSenden({ url: "https://hook.example.de/x", geheimnis: "s3cret" }, nachricht);
expect(aufrufe).toHaveLength(1);
const kopfzeilen = aufrufe[0].optionen.headers as Record<string, string>;
const koerper = aufrufe[0].optionen.body as string;
expect(kopfzeilen["Content-Type"]).toBe("application/json");
expect(JSON.parse(koerper)).toMatchObject({ art: "warnung", regel: "status-nicht-aktiv" });
expect(kopfzeilen["X-ProxLedger-Signatur"]).toBe(
`sha256=${createHmac("sha256", "s3cret").update(koerper).digest("hex")}`,
);
});
it("signiert ohne Geheimnis nicht", async () => {
const aufrufe: RequestInit[] = [];
vi.stubGlobal("fetch", async (_url: string, optionen: RequestInit) => {
aufrufe.push(optionen);
return new Response("ok", { status: 200 });
});
await perWebhookSenden(
{ url: "https://hook.example.de/x" },
{ betreff: "t", text: "t", html: "t", daten: {} },
);
expect((aufrufe[0].headers as Record<string, string>)["X-ProxLedger-Signatur"]).toBeUndefined();
});
it("meldet einen Fehlerstatus des Empfängers", async () => {
vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 }));
await expect(
perWebhookSenden(
{ url: "https://hook.example.de/x" },
{ betreff: "t", text: "t", html: "t", daten: {} },
),
).rejects.toThrow(VersandFehler);
});
});
describe.runIf(datenbankVerfuegbar)("Versand", () => {
beforeEach(async () => {
await datenbankLeeren();
vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 }));
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
async function webhookKanal() {
return testPrisma.benachrichtigungskanal.create({
data: {
typ: "WEBHOOK",
name: "Test-Hook",
aktiv: true,
konfiguration: { url: "https://hook.example.de/x" },
},
});
}
async function alert(fingerprint = "status-nicht-aktiv:h1") {
return testPrisma.alert.create({
data: {
fingerprint,
regel: "status-nicht-aktiv",
schwere: "GEFAHR",
titel: "Subscription abgelaufen",
beschreibung: "pve-01",
},
});
}
it("verschickt eine Warnung und protokolliert den Versand", async () => {
await webhookKanal();
const eintrag = await alert();
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
expect(ergebnis.gesendet).toBe(1);
const protokoll = await testPrisma.benachrichtigung.findMany();
expect(protokoll).toHaveLength(1);
expect(protokoll[0].status).toBe("GESENDET");
expect(protokoll[0].alertId).toBe(eintrag.id);
});
it("meldet denselben offenen Alert kein zweites Mal", async () => {
await webhookKanal();
const eintrag = await alert();
await neueAlertsMelden([eintrag.fingerprint]);
const zweiter = await neueAlertsMelden([eintrag.fingerprint]);
expect(zweiter.gesendet).toBe(0);
expect(zweiter.uebersprungen).toBe(1);
expect(await testPrisma.benachrichtigung.count({ where: { status: "GESENDET" } })).toBe(1);
});
it("verschickt nichts, solange kein Kanal eingerichtet ist", async () => {
const eintrag = await alert();
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
expect(ergebnis.gesendet).toBe(0);
expect(await testPrisma.benachrichtigung.count()).toBe(0);
});
it("protokolliert einen fehlgeschlagenen Versand mit Grund", async () => {
await webhookKanal();
const eintrag = await alert();
vi.stubGlobal("fetch", async () => new Response("kaputt", { status: 503 }));
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
expect(ergebnis.fehlgeschlagen).toBe(1);
const protokoll = await testPrisma.benachrichtigung.findFirstOrThrow();
expect(protokoll.status).toBe("FEHLGESCHLAGEN");
expect(protokoll.fehler).toContain("503");
});
it("versucht einen fehlgeschlagenen Versand beim nächsten Lauf erneut", async () => {
await webhookKanal();
const eintrag = await alert();
vi.stubGlobal("fetch", async () => new Response("kaputt", { status: 503 }));
await neueAlertsMelden([eintrag.fingerprint]);
vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 }));
const zweiter = await neueAlertsMelden([eintrag.fingerprint]);
expect(zweiter.gesendet).toBe(1);
});
it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => {
await webhookKanal();
await alert();
const ergebnis = await zusammenfassungSenden("taeglich", new Date("2026-09-17T07:00:00Z"));
expect(ergebnis.gesendet).toBe(1);
const protokoll = await testPrisma.benachrichtigung.findFirstOrThrow();
expect(protokoll.betreff).toContain("Tagesübersicht");
// Die Zusammenfassung hängt an keinem einzelnen Alert.
expect(protokoll.alertId).toBeNull();
});
});
+316
View File
@@ -0,0 +1,316 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import {
einkaufBezahltSetzen,
kundenrechnungErfassen,
laufzeitAnlegen,
laufzeitBestaetigen,
laufzeitVerwerfen,
proxmoxRechnungErfassen,
RechnungsFehler,
verkaufBezahltSetzen,
verkaufErstelltSetzen,
} from "@/server/services/rechnungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Brandl Elektrotechnik GmbH", kundennummer: "K-1001" },
});
const hostA = await testPrisma.host.create({
data: { agentId: "a-1", hostname: "pve-01", produkt: "PVE", kundeId: kunde.id },
});
const hostB = await testPrisma.host.create({
data: { agentId: "a-2", hostname: "pve-02", produkt: "PVE", kundeId: kunde.id },
});
const laufzeitA = await testPrisma.laufzeit.create({
data: {
hostId: hostA.id,
key: "pve1c-aaaa",
start: new Date("2026-09-24"),
ende: new Date("2027-09-23"),
status: "VORSCHLAG",
},
});
const laufzeitB = await testPrisma.laufzeit.create({
data: {
hostId: hostB.id,
key: "pve2c-bbbb",
start: new Date("2026-10-08"),
ende: new Date("2027-10-07"),
status: "VORSCHLAG",
},
});
return { kunde, hostA, hostB, laufzeitA, laufzeitB };
}
const EINKAUF = {
nummer: "PX-2026-14790",
datum: new Date("2026-09-10"),
faelligAm: new Date("2026-09-24"),
nettoBetrag: 115,
bezahlt: false,
};
describe.runIf(datenbankVerfuegbar)("Laufzeiten und Rechnungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
describe("Laufzeiten", () => {
it("bestätigt einen Vorschlag", async () => {
const { laufzeitA } = await aufbau();
await laufzeitBestaetigen(laufzeitA.id);
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: laufzeitA.id } });
expect(danach.status).toBe("BESTAETIGT");
});
it("verwirft einen Vorschlag, aber keine bestätigte Laufzeit", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await laufzeitVerwerfen(laufzeitA.id);
expect(await testPrisma.laufzeit.findUnique({ where: { id: laufzeitA.id } })).toBeNull();
await laufzeitBestaetigen(laufzeitB.id);
await expect(laufzeitVerwerfen(laufzeitB.id)).rejects.toThrow(RechnungsFehler);
});
it("legt eine Laufzeit von Hand an und verhindert doppelte Startdaten", async () => {
const { hostA } = await aufbau();
const daten = {
hostId: hostA.id,
key: "pve1c-manuell",
start: new Date("2028-01-01"),
ende: new Date("2028-12-31"),
};
await laufzeitAnlegen(daten);
await expect(laufzeitAnlegen(daten)).rejects.toThrow(RechnungsFehler);
await expect(
laufzeitAnlegen({ ...daten, start: new Date("2029-01-01"), ende: new Date("2028-12-31") }),
).rejects.toThrow(RechnungsFehler);
});
});
describe("Proxmox-Rechnung", () => {
it("erfasst eine Rechnung und bestätigt die Laufzeit dabei", async () => {
const { laufzeitA } = await aufbau();
const ergebnis = await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
expect(ergebnis.sammelrechnung).toBe(false);
const laufzeit = await testPrisma.laufzeit.findUniqueOrThrow({
where: { id: laufzeitA.id },
include: { proxmoxPosition: { include: { rechnung: true } } },
});
expect(laufzeit.status).toBe("BESTAETIGT");
expect(laufzeit.proxmoxPosition?.rechnung.nummer).toBe("PX-2026-14790");
expect(Number(laufzeit.proxmoxPosition?.nettoBetrag)).toBe(115);
expect(laufzeit.proxmoxPosition?.rechnung.faelligAm?.toISOString().slice(0, 10)).toBe("2026-09-24");
});
it("hängt eine zweite Laufzeit an dieselbe Rechnungsnummer an (Sammelrechnung)", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
const zweite = await proxmoxRechnungErfassen(laufzeitB.id, { ...EINKAUF, nettoBetrag: 220 });
expect(zweite.sammelrechnung).toBe(true);
expect(await testPrisma.proxmoxRechnung.count()).toBe(1);
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: "PX-2026-14790" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(2);
// Der Rechnungsbetrag ist die Summe der Positionen.
expect(Number(rechnung.nettoBetrag)).toBe(335);
});
it("ändert eine bereits erfasste Rechnung, statt eine zweite Position anzulegen", async () => {
const { laufzeitA } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await proxmoxRechnungErfassen(laufzeitA.id, { ...EINKAUF, nettoBetrag: 130, bezahlt: true });
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: "PX-2026-14790" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(1);
expect(Number(rechnung.nettoBetrag)).toBe(130);
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
});
it("verlangt eine Rechnungsnummer", async () => {
const { laufzeitA } = await aufbau();
await expect(proxmoxRechnungErfassen(laufzeitA.id, { ...EINKAUF, nummer: " " })).rejects.toThrow(
RechnungsFehler,
);
});
});
describe("Kundenrechnung", () => {
it("erfasst eine Kundenrechnung mit Position", async () => {
const { laufzeitA, kunde } = await aufbau();
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0001",
datum: new Date("2026-10-01"),
nettoBetrag: 165,
bezahlt: false,
});
const rechnung = await testPrisma.kundenrechnung.findUniqueOrThrow({
where: { nummer: "RE-2026-0001" },
include: { positionen: true },
});
expect(rechnung.kundeId).toBe(kunde.id);
expect(rechnung.erstellt).toBe(true);
expect(Number(rechnung.nettoBetrag)).toBe(165);
expect(rechnung.positionen).toHaveLength(1);
});
it("fasst mehrere Laufzeiten desselben Kunden zu einer Rechnung zusammen", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0002",
nettoBetrag: 165,
bezahlt: false,
});
const zweite = await kundenrechnungErfassen(laufzeitB.id, {
erstellt: true,
nummer: "RE-2026-0002",
nettoBetrag: 320,
bezahlt: false,
});
expect(zweite.sammelrechnung).toBe(true);
const rechnung = await testPrisma.kundenrechnung.findUniqueOrThrow({
where: { nummer: "RE-2026-0002" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(2);
expect(Number(rechnung.nettoBetrag)).toBe(485);
});
it("weist eine Rechnungsnummer ab, die einem anderen Kunden gehört", async () => {
const { laufzeitA } = await aufbau();
const fremderKunde = await testPrisma.kunde.create({
data: { name: "Huber Logistik AG", kundennummer: "K-1002" },
});
const fremderHost = await testPrisma.host.create({
data: { agentId: "a-3", hostname: "pve-huber", produkt: "PVE", kundeId: fremderKunde.id },
});
const fremdeLaufzeit = await testPrisma.laufzeit.create({
data: {
hostId: fremderHost.id,
key: "pve1c-cccc",
start: new Date("2026-11-01"),
ende: new Date("2027-10-31"),
status: "BESTAETIGT",
},
});
await kundenrechnungErfassen(fremdeLaufzeit.id, {
erstellt: true,
nummer: "RE-2026-0003",
nettoBetrag: 100,
bezahlt: false,
});
await expect(
kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0003",
nettoBetrag: 165,
bezahlt: false,
}),
).rejects.toThrow(RechnungsFehler);
});
it("lehnt eine Kundenrechnung für einen Host ohne Kunde ab", async () => {
const host = await testPrisma.host.create({
data: { agentId: "a-ohne", hostname: "pve-ohne-kunde", produkt: "PVE" },
});
const laufzeit = await testPrisma.laufzeit.create({
data: {
hostId: host.id,
key: "pve1c-dddd",
start: new Date("2026-09-01"),
ende: new Date("2027-08-31"),
status: "BESTAETIGT",
},
});
await expect(
kundenrechnungErfassen(laufzeit.id, { erstellt: true, bezahlt: false, nettoBetrag: 10 }),
).rejects.toThrow(RechnungsFehler);
});
});
describe("Häkchen in der Tabelle", () => {
it("setzt „Proxmox bezahlt“ samt Bezahldatum und nimmt es zurück", async () => {
const { laufzeitA } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await einkaufBezahltSetzen(laufzeitA.id, true);
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } });
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
await einkaufBezahltSetzen(laufzeitA.id, false);
rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } });
expect(rechnung.bezahlt).toBe(false);
expect(rechnung.bezahltAm).toBeNull();
});
it("meldet sich, wenn noch keine Proxmox-Rechnung erfasst ist", async () => {
const { laufzeitA } = await aufbau();
await expect(einkaufBezahltSetzen(laufzeitA.id, true)).rejects.toThrow(RechnungsFehler);
});
it("legt beim Häkchen „Kundenrechnung erstellt“ eine Rechnung ohne Nummer an", async () => {
const { laufzeitA } = await aufbau();
await verkaufErstelltSetzen(laufzeitA.id, true);
const laufzeit = await testPrisma.laufzeit.findUniqueOrThrow({
where: { id: laufzeitA.id },
include: { kundenPosition: { include: { rechnung: true } } },
});
expect(laufzeit.kundenPosition?.rechnung.erstellt).toBe(true);
expect(laufzeit.kundenPosition?.rechnung.nummer).toBeNull();
// Die Nummer lässt sich später nachtragen, ohne dass eine zweite Rechnung entsteht.
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0009",
nettoBetrag: 165,
bezahlt: false,
});
expect(await testPrisma.kundenrechnung.count()).toBe(1);
const nachgetragen = await testPrisma.kundenrechnung.findFirstOrThrow();
expect(nachgetragen.nummer).toBe("RE-2026-0009");
expect(Number(nachgetragen.nettoBetrag)).toBe(165);
});
it("setzt „Kundenrechnung bezahlt“", async () => {
const { laufzeitA } = await aufbau();
await verkaufErstelltSetzen(laufzeitA.id, true);
await verkaufBezahltSetzen(laufzeitA.id, true);
const rechnung = await testPrisma.kundenrechnung.findFirstOrThrow();
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
let verzeichnis: string;
beforeAll(() => {
verzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-uploads-"));
process.env.UPLOAD_DIR = verzeichnis;
});
afterAll(() => {
rmSync(verzeichnis, { recursive: true, force: true });
});
/** Kleinstmögliche gültige PDF-Datei. */
function pdf(inhalt = "Testrechnung"): File {
return new File([`%PDF-1.4\n${inhalt}\n%%EOF\n`], "rechnung.pdf", { type: "application/pdf" });
}
describe.runIf(datenbankVerfuegbar)("PDF-Upload", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("nimmt ein PDF an und legt Datei samt Datensatz an", async () => {
const { anhangSpeichern } = await import("@/lib/uploads");
const id = await anhangSpeichern(pdf());
const anhang = await testPrisma.anhang.findUniqueOrThrow({ where: { id } });
expect(anhang.mimeTyp).toBe("application/pdf");
expect(anhang.dateiname).toBe("rechnung.pdf");
expect(anhang.sha256).toHaveLength(64);
expect(readdirSync(verzeichnis)).toContain(`${anhang.sha256}.pdf`);
});
it("speichert dieselbe Datei nicht zweimal", async () => {
const { anhangSpeichern } = await import("@/lib/uploads");
const erste = await anhangSpeichern(pdf());
const zweite = await anhangSpeichern(pdf());
expect(zweite).toBe(erste);
expect(await testPrisma.anhang.count()).toBe(1);
});
it("lehnt Dateien ab, die kein PDF sind – auch bei passendem Dateinamen", async () => {
const { anhangSpeichern, UploadFehler } = await import("@/lib/uploads");
const getarnt = new File(["<?php system($_GET[0]); ?>"], "rechnung.pdf", { type: "application/pdf" });
await expect(anhangSpeichern(getarnt)).rejects.toThrow(UploadFehler);
expect(await testPrisma.anhang.count()).toBe(0);
});
it("lehnt leere und zu große Dateien ab", async () => {
const { anhangSpeichern, MAX_GROESSE, UploadFehler } = await import("@/lib/uploads");
await expect(anhangSpeichern(new File([], "leer.pdf"))).rejects.toThrow(UploadFehler);
const zuGross = new File(["%PDF-1.4\n" + "x".repeat(MAX_GROESSE)], "gross.pdf");
await expect(anhangSpeichern(zuGross)).rejects.toThrow(UploadFehler);
});
});
+317
View File
@@ -0,0 +1,317 @@
import { describe, expect, it } from "vitest";
import {
alleRegelnPruefen,
REGELN,
type HostDaten,
type LaufzeitDaten,
type RegelKontext,
} from "@/server/alerts/regeln";
const JETZT = new Date("2026-09-17T12:00:00Z");
const TAG = 86_400_000;
function inTagen(tage: number): Date {
return new Date(JETZT.getTime() + tage * TAG);
}
function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
return {
id: "h1",
hostname: "pve-01",
kundeId: "k1",
kundeName: "Brandl Elektrotechnik GmbH",
subKey: "pve1c-aaaa",
subStatus: "ACTIVE",
subNextduedate: inTagen(200),
subLizenzierteSockets: 1,
sockets: 1,
letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
...überschreibungen,
};
}
function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten {
return {
id: "l1",
hostId: "h1",
hostname: "pve-01",
kundeId: "k1",
kundeName: "Brandl Elektrotechnik GmbH",
key: "pve1c-aaaa",
start: inTagen(-100),
ende: inTagen(265),
einkaufNummer: "PX-2026-1",
einkaufBetrag: 115,
einkaufBezahlt: true,
einkaufFaelligAm: inTagen(-80),
verkaufNummer: "RE-2026-1",
verkaufBetrag: 165,
verkaufErstellt: true,
verkaufBezahlt: true,
verkaufDatum: inTagen(-90),
...überschreibungen,
};
}
function kontext(teil: Partial<RegelKontext> = {}): RegelKontext {
return {
jetzt: JETZT,
hosts: [],
laufzeiten: [],
einstellungen: new Map(),
...teil,
};
}
/** Hilfsfunktion: Kandidaten einer bestimmten Regel. */
function ausRegel(k: RegelKontext, name: string) {
return alleRegelnPruefen(k).filter((kandidat) => kandidat.regel === name);
}
describe("Warnregeln", () => {
it("prüft alle zehn Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(10);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(10);
});
describe("Subscription läuft ab", () => {
it.each([
[31, 0, null],
[30, 1, "INFO"],
[20, 1, "INFO"],
[14, 1, "WARNUNG"],
[8, 1, "WARNUNG"],
[7, 1, "GEFAHR"],
[1, 1, "GEFAHR"],
[0, 1, "GEFAHR"],
[-1, 0, null],
])("bei %i Tagen Restlaufzeit: %i Warnung(en)", (tage, anzahl, schwere) => {
const kandidaten = ausRegel(
kontext({ hosts: [host({ subNextduedate: inTagen(tage) })] }),
"subscription-laeuft-ab",
);
expect(kandidaten).toHaveLength(anzahl);
if (schwere) expect(kandidaten[0].schwere).toBe(schwere);
});
it("meldet nur die strengste erreichte Stufe", () => {
const kandidaten = ausRegel(
kontext({ hosts: [host({ subNextduedate: inTagen(5) })] }),
"subscription-laeuft-ab",
);
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].fingerprint).toBe("subscription-laeuft-ab:h1:7");
});
it("beachtet eigene Schwellwerte", () => {
const k = kontext({
hosts: [host({ subNextduedate: inTagen(50) })],
einstellungen: new Map([
["subscription-laeuft-ab", { aktiv: true, schwellwerte: { tage: [60, 30] } }],
]),
});
expect(ausRegel(k, "subscription-laeuft-ab")).toHaveLength(1);
});
it("meldet nichts für Hosts ohne aktive Subscription", () => {
const k = kontext({ hosts: [host({ subStatus: "EXPIRED", subNextduedate: inTagen(3) })] });
expect(ausRegel(k, "subscription-laeuft-ab")).toHaveLength(0);
});
});
describe("Status nicht aktiv", () => {
it.each([
["EXPIRED", "GEFAHR"],
["INVALID", "GEFAHR"],
["SUSPENDED", "GEFAHR"],
["NOTFOUND", "WARNUNG"],
["UNKNOWN", "WARNUNG"],
] as const)("meldet %s als %s", (status, schwere) => {
const kandidaten = ausRegel(kontext({ hosts: [host({ subStatus: status })] }), "status-nicht-aktiv");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].schwere).toBe(schwere);
});
it("meldet aktive Subscriptions nicht", () => {
expect(ausRegel(kontext({ hosts: [host()] }), "status-nicht-aktiv")).toHaveLength(0);
});
});
describe("Sockets", () => {
it("meldet eine Abweichung", () => {
const k = kontext({ hosts: [host({ subLizenzierteSockets: 1, sockets: 2 })] });
const kandidaten = ausRegel(k, "sockets-abweichung");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].beschreibung).toContain("1 lizenziert, 2 physisch");
});
it("meldet nichts, wenn ein Wert fehlt", () => {
expect(ausRegel(kontext({ hosts: [host({ sockets: null })] }), "sockets-abweichung")).toHaveLength(0);
expect(
ausRegel(kontext({ hosts: [host({ subLizenzierteSockets: null })] }), "sockets-abweichung"),
).toHaveLength(0);
});
});
describe("Keine Meldung", () => {
it("meldet nach Ablauf der Frist", () => {
const k = kontext({ hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 49 * 3_600_000) })] });
expect(ausRegel(k, "keine-meldung")).toHaveLength(1);
});
it("meldet innerhalb der Frist nicht", () => {
const k = kontext({ hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 47 * 3_600_000) })] });
expect(ausRegel(k, "keine-meldung")).toHaveLength(0);
});
it("beachtet einen eigenen Schwellwert", () => {
const k = kontext({
hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 13 * 3_600_000) })],
einstellungen: new Map([["keine-meldung", { aktiv: true, schwellwerte: { stunden: 12 } }]]),
});
expect(ausRegel(k, "keine-meldung")).toHaveLength(1);
});
});
describe("Server-ID", () => {
it("meldet einen frischen Wechsel", () => {
const k = kontext({
hosts: [
host({
serverIds: [
{ serverId: "ALT", ersteMeldung: inTagen(-400), letzteMeldung: inTagen(-10) },
{ serverId: "NEU", ersteMeldung: inTagen(-5), letzteMeldung: JETZT },
],
}),
],
});
const kandidaten = ausRegel(k, "serverid-geaendert");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].beschreibung).toContain("neu NEU, vorher ALT");
});
it("meldet einen alten Wechsel nicht mehr", () => {
const k = kontext({
hosts: [
host({
serverIds: [
{ serverId: "ALT", ersteMeldung: inTagen(-400), letzteMeldung: inTagen(-200) },
{ serverId: "NEU", ersteMeldung: inTagen(-190), letzteMeldung: JETZT },
],
}),
],
});
expect(ausRegel(k, "serverid-geaendert")).toHaveLength(0);
});
});
describe("Key mehrfach gemeldet", () => {
it("meldet denselben Key auf zwei Hosts", () => {
const k = kontext({
hosts: [host(), host({ id: "h2", hostname: "pve-02" })],
});
const kandidaten = ausRegel(k, "key-mehrfach-gemeldet");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].beschreibung).toContain("pve-01, pve-02");
});
it("meldet unterschiedliche Keys nicht", () => {
const k = kontext({ hosts: [host(), host({ id: "h2", subKey: "pve1c-bbbb" })] });
expect(ausRegel(k, "key-mehrfach-gemeldet")).toHaveLength(0);
});
});
describe("Host ohne Kunde", () => {
it("meldet nicht zugeordnete Hosts", () => {
const k = kontext({ hosts: [host({ kundeId: null, kundeName: null })] });
const kandidaten = ausRegel(k, "host-ohne-kunde");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].schwere).toBe("INFO");
});
});
describe("Proxmox-Rechnung fällig", () => {
it("meldet eine bald fällige unbezahlte Rechnung als Warnung", () => {
const k = kontext({
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(5) })],
});
const kandidaten = ausRegel(k, "proxmox-rechnung-faellig");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].schwere).toBe("WARNUNG");
});
it("meldet eine überfällige Rechnung als Gefahr", () => {
const k = kontext({
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(-2) })],
});
const kandidaten = ausRegel(k, "proxmox-rechnung-faellig");
expect(kandidaten[0].schwere).toBe("GEFAHR");
expect(kandidaten[0].titel).toBe("Proxmox-Rechnung überfällig");
});
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
expect(
ausRegel(kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }), "proxmox-rechnung-faellig"),
).toHaveLength(0);
expect(
ausRegel(
kontext({ laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(20) })] }),
"proxmox-rechnung-faellig",
),
).toHaveLength(0);
});
});
describe("Kundenrechnung", () => {
it("meldet eine fehlende Kundenrechnung, wenn der Einkauf erfasst ist", () => {
const k = kontext({ laufzeiten: [laufzeit({ verkaufErstellt: false, verkaufNummer: null })] });
expect(ausRegel(k, "kundenrechnung-fehlt")).toHaveLength(1);
});
it("meldet nichts für Hosts ohne Kunde", () => {
const k = kontext({
laufzeiten: [laufzeit({ verkaufErstellt: false, kundeId: null, kundeName: null })],
});
expect(ausRegel(k, "kundenrechnung-fehlt")).toHaveLength(0);
});
it("meldet eine seit über 30 Tagen unbezahlte Kundenrechnung", () => {
const k = kontext({
laufzeiten: [laufzeit({ verkaufBezahlt: false, verkaufDatum: inTagen(-31) })],
});
const kandidaten = ausRegel(k, "kundenrechnung-unbezahlt");
expect(kandidaten).toHaveLength(1);
expect(kandidaten[0].titel).toContain("31 Tagen");
});
it("meldet innerhalb der Frist nicht", () => {
const k = kontext({
laufzeiten: [laufzeit({ verkaufBezahlt: false, verkaufDatum: inTagen(-29) })],
});
expect(ausRegel(k, "kundenrechnung-unbezahlt")).toHaveLength(0);
});
});
it("überspringt abgeschaltete Regeln", () => {
const k = kontext({
hosts: [host({ kundeId: null, kundeName: null })],
einstellungen: new Map([["host-ohne-kunde", { aktiv: false, schwellwerte: {} }]]),
});
expect(ausRegel(k, "host-ohne-kunde")).toHaveLength(0);
});
it("erzeugt für jeden Fall einen eindeutigen Fingerabdruck", () => {
const k = kontext({
hosts: [
host({ subStatus: "EXPIRED", sockets: 2, kundeId: null, kundeName: null }),
host({ id: "h2", hostname: "pve-02", subStatus: "NOTFOUND" }),
],
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(1), verkaufErstellt: false })],
});
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
expect(new Set(fingerprints).size).toBe(fingerprints.length);
});
});