Compare commits

...

188 Commits

Author SHA1 Message Date
6455be81bd KI-AGENT: Beleg-Relationswerte vor dem Speichern normalisieren
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 51s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-19 16:53:53 +02:00
9cde630562 KI-AGENT: Datumswerte in Beleg-Relationen bereinigen 2026-05-19 16:48:22 +02:00
48d101e139 KI-AGENT: Fehlerhafte Serienausführung bei Belegen abfangen 2026-05-19 16:31:57 +02:00
167e9a40c3 KI-AGENT: Ergänze Kalender-Abo für Mitarbeiterprofile 2026-05-19 16:26:49 +02:00
f9d3f10eae Dateityp-Belegarten in Detailinfo anzeigen 2026-05-19 15:26:44 +02:00
6d9bceb63f Doppelte Abschlagsrechnung in Auswahl entfernen 2026-05-19 15:25:04 +02:00
e29e84898b Abschlagsrechnung als Dateityp ergänzen 2026-05-19 15:21:44 +02:00
1ccabbedcd Dateityp-Belegarten in Listen anzeigen 2026-05-19 15:20:38 +02:00
24febf4c95 Dateityp-Belegarten als Auswahl pflegen 2026-05-19 15:12:27 +02:00
5fc7cc9604 Dateimodal überarbeiten und Dateitypen pflegen 2026-05-19 12:47:51 +02:00
941f1d819b KI-AGENT: Leite Termine nach dem Speichern zurück zur Plantafel 2026-05-19 12:31:39 +02:00
58c47fa8f7 KI-AGENT: Ergänze wiederholende Termine für Kalender und Plantafel 2026-05-19 12:27:17 +02:00
ea392af094 Ergänze Entwurfsstatus für Termine und Plantafel 2026-05-19 12:18:30 +02:00
0ac22d346f KI-AGENT: Bildvorschau im Chat authentifiziert laden 2026-05-19 10:55:36 +02:00
26ffc4421a KI-AGENT: Anhänge im Chat über Matrix unterstützen 2026-05-19 10:51:33 +02:00
7caa37378b KI-AGENT: Chat Benachrichtigungen und Ungelesen-Zähler umsetzen 2026-05-19 08:39:26 +02:00
227a88b24b KI-AGENT: Chatgruppen einklappbar machen 2026-05-19 08:33:17 +02:00
0fb469c9b0 KI-AGENT: Chaträume sortieren und Matrix-Setup verschieben 2026-05-19 08:31:40 +02:00
5b3445c2dc KI-AGENT: Projekträume und Direktnachrichten integrieren 2026-05-19 08:27:39 +02:00
716de8a503 KI-AGENT: Verschlüssele Bankverbindungen beim Import neu 2026-05-19 08:19:25 +02:00
817d0e814b KI-AGENT: Erhalte Bankkonten und Buchungszuordnungen beim Import
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 19s
Build and Push Docker Images / build-frontend (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-18 22:08:53 +02:00
75d5e2b72d Revert "KI-AGENT: Exportiere Sachkonten beim Mandantenumzug"
This reverts commit 30aaf141c7.
2026-05-18 22:07:47 +02:00
30aaf141c7 KI-AGENT: Exportiere Sachkonten beim Mandantenumzug 2026-05-18 22:05:53 +02:00
c7ba7a9cc5 KI-AGENT: Importiere Export in bestehenden Zielmandanten
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 49s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-18 21:49:12 +02:00
1c68e6b724 KI-AGENT: Aktiviere importierten Mandanten für Zieladmin 2026-05-18 21:47:28 +02:00
cc3c405473 KI-AGENT: Überspringe generierte Spalten beim Mandantenimport
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 10s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-18 21:40:47 +02:00
ff70338b21 KI-AGENT: Serialisiere JSON-Felder beim Mandantenimport
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 10s
2026-05-18 21:35:16 +02:00
bb3b842be1 KI-AGENT: Ergänze Mandantenexport und Import
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 51s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-18 21:23:18 +02:00
9c6a6a841a KI-AGENT: Fange fehlende Dokument-Auswahldaten ab 2026-05-18 21:15:34 +02:00
b7b913035e KI-AGENT: Briefpapier-Upload und Dateianzeige korrigieren 2026-05-18 21:10:41 +02:00
454e9ee3c9 KI-AGENT: Briefpapierpflege im Frontend ergänzen 2026-05-18 21:06:50 +02:00
01846d488b KI-AGENT: Traefik-Netzwerk für Selfhosting festlegen 2026-05-18 20:56:10 +02:00
80b2b1d097 KI-AGENT: Profil-Verfügbarkeitsmigration im Journal ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 1m22s
Build and Push Docker Images / build-frontend (push) Successful in 2m27s
Build and Push Docker Images / build-docs (push) Successful in 59s
2026-05-18 20:15:58 +02:00
f5755993b5 KI-AGENT: Sharp im Backend-Container für Linux installieren 2026-05-18 20:12:19 +02:00
0f56102030 KI-AGENT: Wiederholte Sammel-Migration leeren 2026-05-18 20:10:22 +02:00
60d846baa9 KI-AGENT: Doppelte Vertragstypen-Migration bereinigen 2026-05-18 20:08:24 +02:00
a28b910d4d KI-AGENT: Doppelte Zeiterfassungs-Migration bereinigen 2026-05-18 20:02:29 +02:00
4aeefb2b83 KI-AGENT: Zentrale Benachrichtigungsengine mit Desktop Push umsetzen 2026-05-18 19:51:08 +02:00
24c09d7891 KI-AGENT: Kamera und Bildschirmfreigabe getrennt anzeigen 2026-05-18 19:28:53 +02:00
77eabe7e18 KI-AGENT: FEDEO-Call-Erlebnis ausbauen 2026-05-18 19:24:12 +02:00
e4073e01ad KI-AGENT: Lokale LiveKit-Verbindung für FEDEO-Calls korrigieren 2026-05-18 19:18:43 +02:00
248da3412c KI-AGENT: LiveKit-Calls nativ in FEDEO integrieren 2026-05-18 19:15:36 +02:00
c93ea4284d KI-AGENT: Matrix-Einbettung stabilisieren 2026-05-18 18:56:37 +02:00
7c68ce61f2 KI-AGENT: Matrix-Anrufe im Chat vorbereiten 2026-05-18 18:38:21 +02:00
f6dd37b458 KI-AGENT: Matrix-Teilnehmer synchronisieren 2026-05-18 18:33:17 +02:00
bb54a8779e KI-AGENT: Chaträume im Frontend verwalten 2026-05-18 18:23:14 +02:00
6e14f48770 KI-AGENT: Matrix-Räume persistent verwalten 2026-05-18 18:19:23 +02:00
4d24e3a657 KI-AGENT: Erstzugriff und Mandanten-Grunddaten für Selfhosting ergänzen 2026-05-18 18:17:23 +02:00
f33ccf730a KI-AGENT: Matrix-Raum-API verallgemeinern 2026-05-18 18:12:26 +02:00
8824b1c9c8 KI-AGENT: Selfhosting für Secrets, Compose und Migrationen vorbereiten 2026-05-18 18:06:03 +02:00
571c24f250 KI-AGENT: Chat in eigene Kommunikationsseite auslagern 2026-05-18 18:01:54 +02:00
b03af21e97 KI-AGENT: Matrix-Login-Rate-Limits vermeiden 2026-05-18 17:55:41 +02:00
b1e102ca5d KI-AGENT: Matrix-Chat Rate-Limits reduzieren 2026-05-18 17:49:31 +02:00
8b40be7909 KI-AGENT: Matrix-Chat live aktualisieren 2026-05-18 17:45:56 +02:00
655459a46b KI-AGENT: Nativen Matrix-Chat in FEDEO starten 2026-05-18 17:41:31 +02:00
5fca7792a2 KI-AGENT: Scrollen auf Kommunikationsseite ermöglichen 2026-05-18 17:35:10 +02:00
30b6ffcc20 KI-AGENT: Matrix-Chat in FEDEO einbetten 2026-05-18 17:29:34 +02:00
7f66f66cfa KI-AGENT: Matrix-Räume in FEDEO provisionieren 2026-05-18 17:24:46 +02:00
d0de3cb92e KI-AGENT: Matrix-Mandanten-Space provisionieren 2026-05-18 17:11:52 +02:00
c893574cb1 KI-AGENT: Matrix-Kontoerstellung nutzbarer machen 2026-05-18 16:59:35 +02:00
eb2dd03ef9 KI-AGENT: Matrix-Kommunikation im Frontend anbinden 2026-05-18 15:42:10 +02:00
b322d0c173 KI-AGENT: Erste Matrix-Backendintegration ergänzen 2026-05-18 15:37:12 +02:00
54ae136f0d KI-AGENT: LiveKit-Start im Matrix-Stack korrigieren 2026-05-18 15:33:34 +02:00
00e1e88dd9 KI-AGENT: Lokalen Matrix-Entwicklungsstack ergänzen 2026-05-18 15:24:43 +02:00
3984e218db KI-AGENT: Matrix-Stack in Docker Compose vorbereiten 2026-05-18 14:58:27 +02:00
d9c3c8d07c KI-AGENT: Matrix-Kommunikationslösung entwerfen 2026-05-18 14:38:41 +02:00
c6a0d59c29 SEPA-Exportdatei aus Mandaten erzeugen #182 2026-05-15 18:36:48 +02:00
9592e2b062 SEPA-Mandate in Verträge und Ausgangsrechnungen einbauen #183 2026-05-15 18:13:29 +02:00
d522cbb49d Bankverbindungs-Buttons bei SEPA-Mandaten ergänzen #183 2026-05-15 18:06:43 +02:00
8d7bc2e97c SEPA-Mandatsauswahlen als Wörterbücher pflegen #183 2026-05-15 18:00:57 +02:00
44017a768b Ausgehende SEPA-Mandate einführen #183 2026-05-15 17:47:11 +02:00
683d073b6e KI-AGENT: MCP-Belegpositionen für Angebote normalisieren 2026-05-15 17:11:31 +02:00
cb939f2197 DATEV-Export Datumsformat korrigieren #181
KI-AGENT: Normalisiert Export-Zeiträume und Belegdatumsfelder vor der DATEV-Ausgabe auf Europe/Berlin, damit per UI gewählte Daten nicht durch UTC-Verschiebung auf den Vortag fallen.
2026-05-15 16:54:34 +02:00
0e71899c57 DATEV-BU-Schlüssel für Ausgangsbelege entfernen #179 2026-05-13 09:52:22 +02:00
6b82f2b629 DATEV-Erlöskonten für Ausgangsbelege aufteilen #179 2026-05-13 09:48:52 +02:00
9ba5f26efc MCP Upload für Eingangsbelege ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m2s
Build and Push Docker Images / build-docs (push) Successful in 15s
2026-05-12 19:04:37 +02:00
82f2143dd1 MCP Tokenverwaltung in Firmeneinstellungen ergänzen 2026-05-12 18:25:30 +02:00
4e49dd18a1 MCP um dauerhafte tenantgebundene Tokens erweitern
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m4s
Build and Push Docker Images / build-docs (push) Successful in 15s
2026-05-12 17:39:20 +02:00
252021acee KI-AGENT: Entwürfe bei offenen Belegen anzeigen #180 2026-05-12 11:32:58 +02:00
eae321b364 DATEV-Steuerschlüssel für 13b-Ausgangsrechnungen setzen #179 2026-05-12 11:21:09 +02:00
5a2682c835 KI-AGENT: Wiki-Neuanlage repariert #178
Der Wiki-Neu-Button nutzt nun das aktuelle DropdownMenu mit onSelect-Callbacks, sodass neue Seiten und Ordner wieder erstellt werden können.
2026-05-12 10:07:55 +02:00
34f537238e Kundenanlage mit aktiven Standardwerten vorbelegen #120
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
Build and Push Docker Images / build-docs (push) Successful in 16s
2026-05-11 18:20:59 +02:00
64df33f0fa Ausgangsbeleg-Toolbar aufräumen #110
Gruppiert Verknüpfungen in ein Dropdown und lädt Bankbuchungen für Ausgangsbelege gezielt mit Bankstatement-Relation nach, damit das Bankbuchungsdatum korrekt angezeigt wird.
2026-05-11 18:15:01 +02:00
94ab3350ec Belegansichten um Bankbuchungsdatum erweitern #110
Zeigt Bankbuchungsdaten in Ausgangsbelegen direkt an und ergänzt Bankdetails im Modal. Überarbeitet die Eingangsbeleg-Showansicht zu einer echten Lesedarstellung mit Bankbuchungsdaten und Positionsübersicht.
2026-05-11 18:04:05 +02:00
aa162dcad3 KI-AGENT: Erweitert Länderauswahl für Lieferanten (#157)
Die Länderauswahl wird beim Laden der Spezialressource um eine vollständige deutschsprachige Standardliste ergänzt. Bestehende Datenbankeinträge bleiben erhalten und überschreiben die Standardwerte.
2026-05-11 17:52:59 +02:00
c42e57494a Behebt Breadcrumb-Navigation im Dateimanager
Issue 170: Die Dateimanager-Breadcrumbs nutzen jetzt die Nuxt-UI-Items-API, zeigen den aktuellen Ordnerpfad an und erlauben die Navigation zu übergeordneten Ordnern über die Route.
2026-05-11 17:41:39 +02:00
582af62fcb Korrigiert Kassenbuch Navbar Struktur
Die Kassenbuch-Listenansicht rendert die DashboardNavbar nun wie andere Listenseiten direkt oben. Die Detailansicht nutzt dieselbe Struktur und hält den Inhalt im PanelContent.
2026-05-11 17:30:42 +02:00
743c0e8772 Verschiebt Kassenbuch Infos in Navbar
Die Detailinformationen zum Kassenbuch werden nun in der Navbar angezeigt. Die separate Kartenzeile oberhalb der Buchungsmaske entfällt.
2026-05-11 17:27:46 +02:00
d4c39d7d44 Passt Kassenbuch Ansicht an
Kassenbücher werden nun zuerst tabellarisch angezeigt. Das Erstellen einer Barkasse erfolgt über ein Modal und einzelne Kassenbücher öffnen sich über eine Detailseite.
2026-05-11 17:25:44 +02:00
e60188f043 Dokument-Steuertypen im FEDEO MCP ergänzen
Erweitert die Ausgangsbeleg-Tools um explizite Dokument-Steuertypen. Der MCP listet die unterstützten Steuertypen, validiert taxType als Dokumentfeld und setzt bei 13b UStG, 19 UStG und 12.3 UStG die Positions-USt analog zur Oberfläche auf 0 Prozent.
2026-05-11 17:25:10 +02:00
ca4f1ba1c0 Eingangsbelege im FEDEO MCP bearbeitbar machen
Ergänzt MCP-Tools zum Prüfen, Bearbeiten, Erstellen, Buchen und Archivieren von Eingangsbelegen. Die Validierung spiegelt die Pflichtregeln aus der FEDEO-Oberfläche und verhindert das Buchen unvollständiger Belege.
2026-05-11 17:17:09 +02:00
5fe823f52a Implementiert Kassenbuch für Barkassen
Barkassen können als manuelle Bankkonten angelegt werden. Kassenbuchungen erzeugen Bankbewegungen mit Gegenkonto und sind über eine neue Buchhaltungsseite erreichbar.
2026-05-11 17:13:49 +02:00
1969610130 Finalisieren von MCP-Ausgangsbelegen korrigieren
Passt die Ausgangsbeleg-Tools an die bestehende FEDEO-Logik an: Entwürfe erhalten keine Belegnummer, Create und Update akzeptieren keine manuellen Nummern mehr, und die Nummernvergabe erfolgt ausschließlich über ein separates Finalisieren-Tool.
2026-05-11 16:38:30 +02:00
2bf52b35fe Ausgangsbelege im FEDEO MCP ergänzen
Erweitert den Accounting-MCP um lesende und schreibende Tools für Ausgangsbelege. Belege können gelistet, geladen, als Entwurf erstellt, aktualisiert und archiviert werden; optional wird beim Schreiben eine Belegnummer aus dem passenden Nummernkreis gezogen.
2026-05-11 16:33:28 +02:00
f01881a6ce Stammdaten-Tools für FEDEO MCP ergänzen
Ergänzt eine eigene MCP-Toolgruppe für Stammdaten. Enthalten sind read-only Werkzeuge für Kunden, Lieferanten, Kontakte, Artikel, Leistungen, Kostenstellen, Niederlassungen, Teams, Fahrzeuge, Inventar und Einheiten.
2026-05-11 13:12:01 +02:00
a185c6eb11 FEDEO MCP-Tools für Organisation erweitern
Ergänzt MCP-Tools für Kunden-, Projekt-, Anlagen- und Terminabfragen sowie das Laden einzelner Bankumsätze. Aufgaben können nun zusätzlich gezielt archiviert werden.
2026-05-11 13:07:11 +02:00
a8450fc0c6 MCP-Server für Buchhaltung und Organisation ergänzen
Fügt einen geschützten MCP-JSON-RPC-Endpunkt mit Buchhaltungs-Tools und Aufgaben-Tools hinzu. Berechtigungen werden rollenbasiert pro Mandant geprüft und die Auth-Logik berücksichtigt nun alle Rollen eines Nutzers.
2026-05-11 12:43:58 +02:00
0f5275b870 Ausgangsrechnungen zeigen korrektes Belegdatum
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m34s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-docs (push) Successful in 17s
2026-05-08 20:24:58 +02:00
4f37811dcc Suche im zentralen Logbuch hinzufügen 2026-05-08 20:20:44 +02:00
d7eced3e77 Vertragstypen für Änderungsanfragen pflegen 2026-05-08 20:09:24 +02:00
6a5c1e844d Vertragstypen im Kundenportal freigeben 2026-05-08 20:04:26 +02:00
5dc44e571f Kundenportal Vertragsanfragen ergänzen 2026-05-08 20:01:57 +02:00
2b1a9a456b Ausgangsbelege nach offenen Belegen filterbar machen 2026-05-08 19:36:48 +02:00
bf5d7aaed2 Dateien in Organisation verschoben 2026-05-08 19:27:00 +02:00
e166248c0d Mitarbeiternavigation neu gruppiert 2026-05-08 19:25:07 +02:00
cba4ea52e8 Bearbeiten-Aktion im Plantafel-Mitarbeitermodal ergänzen 2026-04-29 16:37:12 +02:00
0f14f7ac3d Verfügbarkeitshinweise für Mitarbeiter und Plantafel-Details ergänzen 2026-04-29 16:33:39 +02:00
2d26cedaa3 Benutzeranlage direkt aus Mitarbeiterprofil ermöglichen 2026-04-29 16:23:35 +02:00
d5aed2140e Plantafel-Modal für Profile ohne Benutzerzuordnung absichern 2026-04-29 16:19:03 +02:00
cfc5efb556 Plantafel um Mitarbeiterdetails mit Resturlaub erweitern 2026-04-29 16:12:13 +02:00
898a5459fa Ergänze Team-Zuordnung im Mitarbeiterimport 2026-04-29 15:58:32 +02:00
3b7bcb7940 Erweitere Dry-Run-Ausgabe für Mitarbeiterimport 2026-04-29 15:51:07 +02:00
2aaff0088e Füge Branch-Mapping für Mitarbeiterlisten-Import von Tenant 41 hinzu 2026-04-29 15:41:42 +02:00
e9bbc196f7 Füge Importskript für Mitarbeiterlisten mit Tenant- und Niederlassungszuordnung hinzu 2026-04-28 14:06:09 +02:00
20818beb3a Deaktivated Verify Docs Sync
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s
Build and Push Docker Images / build-docs (push) Successful in 1m25s
2026-04-27 09:20:32 +02:00
6aa69cb68b Deaktivated Verify Docs Sync
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-docs (push) Has been cancelled
2026-04-27 09:19:33 +02:00
a021d3d15c Time Changes
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 9s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-27 09:17:36 +02:00
bb61caed6d Unterkostenstellen in der Kostenstellenauswertung korrekt auflösen 2026-04-24 23:03:23 +02:00
d3ab03da7e Kostenstellenhierarchie und Auswertung mit Unterkostenstellen ergänzt 2026-04-24 22:56:33 +02:00
5869f88c1a Vereinheitliche Empty States in Tabellen 2026-04-24 14:42:53 +02:00
50c76b67c7 Serienvorlagen in Liquiditätsprognose einplanen 2026-04-23 22:15:50 +02:00
f4edcc2d44 Rechnungsentwürfe optional wie Rechnungen verwenden 2026-04-23 21:49:45 +02:00
35ef3a7cf8 USt-Auswertung in Liquiditätsprognose integrieren 2026-04-23 21:44:59 +02:00
4783971000 Rechnungsentwürfe optional in Prognose anzeigen
Zeigt Rechnungsentwürfe eingeklammert als optionalen Einfluss in der Liquiditätsprognose an, ohne den eigentlichen Endstand zu verändern.
2026-04-23 21:29:14 +02:00
c085b1e4d5 Liquiditätsprognose besser nachvollziehbar machen
Ergänzt Herleitung, Einflussfaktoren, größte Treiber und eine Tagesaufschlüsselung, damit die Prognosewerte Schritt für Schritt nachvollzogen werden können.
2026-04-23 21:21:15 +02:00
46b08b29b9 Regelmäßige Bewegungen auf Ausgaben begrenzen
Berücksichtigt in der Liquiditätsprognose nur noch negative, also ausgehende, wiederkehrende Bankbewegungen aus Heuristik und KI-Erkennung.
2026-04-23 21:18:11 +02:00
5cc41f9a2d Manuelle Buchungen in Liquiditätsprognose korrekt verrechnen
Berechnet offene Eingangsbelege jetzt mit Absolutbeträgen, damit manuelle Buchungen und Bankzuordnungen unabhängig vom gespeicherten Vorzeichen korrekt in der Prognose erscheinen.
2026-04-23 21:13:04 +02:00
edec670ee0 Eingangsbelege um offenen Betrag ergänzen 2026-04-23 21:06:44 +02:00
41e5a4021b Manuelle Buchungen um zuweisbare Eingangsbelege erweitern 2026-04-23 17:52:56 +02:00
9c608cbf71 Manuelle Buchungen im Menü hinter Bank verschieben 2026-04-23 17:39:22 +02:00
543952dbf8 Manuelle Buchungen Select ohne Leerwert absichern 2026-04-23 17:36:32 +02:00
2f7819e309 Manuelle Buchungen um funktionsfähigen Steuerschlüssel und volle Beschreibungsbreite korrigieren 2026-04-23 17:35:32 +02:00
7799cbce80 Manuelle Buchungen um volle Beschreibungsbreite und einklappbare Kontenlisten ergänzen 2026-04-23 17:33:35 +02:00
0284ea8726 Manuelle Buchungen um DATEV-Steuerschlüssel und getrennte Soll/Haben-Auswahl erweitern 2026-04-23 17:24:58 +02:00
743bf0660c Manuelle Buchungen in Statementallocations integrieren 2026-04-23 16:28:44 +02:00
df4b591be4 Liquiditätsprognose zwischenspeichern
Lädt die Liquiditätsprognose aus einem lokalen Cache und erstellt sie nur noch manuell über den Refresh-Button in der Toolbar neu.
2026-04-23 16:15:04 +02:00
86e0743cbb Bezahlte Eingangsbelege aus Liquiditätsprognose entfernen
Berücksichtigt das Bezahlt-Kennzeichen und korrigiert die Verrechnung von Bankzuordnungen bei offenen Eingangsbelegen.
2026-04-23 16:06:57 +02:00
aaf91ea15e Belege und Bankmuster in Liquiditätsprognose verwalten
Ermöglicht das Öffnen von Belegen aus der Liquiditätsprognose und das Abschließen erkannter regelmäßiger Bankbewegungen, die anschließend aus der Prognose herausgerechnet werden.
2026-04-23 16:03:37 +02:00
cb71e9d294 Archivierte Belege aus Liquiditätsprognose ausschließen
Sichert die Liquiditätsprognose zusätzlich gegen archivierte Ausgangs- und Eingangsbelege sowie deren Zuordnungen ab.
2026-04-23 15:56:46 +02:00
75148b2718 Liquiditätsprognose für Auswertungen ergänzt
Erstellt einen KI-gestützten Backend-Endpunkt für die Liquiditätsprognose, ergänzt die Auswertungsseite mit Verlauf, offenen Belegen und regelmäßigen Bankbewegungen und verlinkt die Funktion in der Navigation.
2026-04-23 15:52:41 +02:00
81b4eee1e8 Vereinfache Bedienungsdoku und ergänze Bankportal-Anleitung 2026-04-22 19:43:37 +02:00
0fbda27609 Verschlanke Dokumentation auf Bedienung und leite Einstieg direkt dorthin 2026-04-22 19:27:17 +02:00
3562d55a12 Richte Nutzerdoku auf Bedienungsanleitung mit Frontend-Seitenkategorie aus 2026-04-22 19:21:32 +02:00
6224a25c38 Gestalte Docs-Landing und Header im Nuxt-UI-Template-Stil 2026-04-22 19:13:11 +02:00
63b1c563c1 Stelle docs-site auf Nuxt-UI-Docs-Template-Stil um 2026-04-22 19:07:15 +02:00
76f86e87c1 Behebe 404 im Nuxt-Devserver durch _path-basierte Content-Abfrage 2026-04-22 18:59:11 +02:00
8c458f4953 Ersetze Docusaurus vollständig durch Nuxt-Content-Docs-App
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 9s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 13s
Build and Push Docker Images / build-docs (push) Successful in 1m47s
2026-04-22 18:16:00 +02:00
d704e343fc Behebe Docs-Dockerbuild durch korrekten Build-Pfad
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 42s
2026-04-22 17:57:15 +02:00
4882da0d35 Stabilisiere Docs-Auslieferung unter /docs mit Traefik StripPrefix
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 7s
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-docs (push) Failing after 41s
Build and Push Docker Images / build-frontend (push) Successful in 14s
2026-04-22 16:06:02 +02:00
1908a6441d Behebe Nginx Redirect-Loop für Docusaurus unter /docs
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 43s
2026-04-22 15:25:42 +02:00
a4735818fb Behebe Docs-Build in CI durch stabile Webpack-Overrides
All checks were successful
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Successful in 1m48s
2026-04-22 15:09:12 +02:00
4fb3d3c8a0 Stabilisiere Workflow-Imagepfad auf flfeders/fedeo für Compose-Kompatibilität
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 14s
Build and Push Docker Images / build-docs (push) Failing after 20s
2026-04-22 14:58:30 +02:00
30dc99e4e0 Integriere Docusaurus in Haupt-Compose mit TLS unter /docs 2026-04-22 14:48:11 +02:00
9fea18b215 Behebe fehlschlagenden Docs-Workflow durch deterministische Doku-Generierung
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Successful in 8s
Build and Push Docker Images / build-backend (push) Successful in 2m50s
Build and Push Docker Images / build-frontend (push) Successful in 3m18s
Build and Push Docker Images / build-docs (push) Failing after 1m29s
2026-04-22 14:38:51 +02:00
75c15c14c4 Commit Doku
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 8s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-22 14:35:22 +02:00
b27b00f59c Erweitere CI-Workflow um Doku-Sync-Prüfung und Docs-Image-Deploy
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 10s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-21 21:40:55 +02:00
1637d4bd91 Bereite Docusaurus-Deploy mit Docker und eigener Docs-Site vor 2026-04-21 19:45:16 +02:00
8114a8c645 Füge versionierbare Funktionsdokumentation mit automatischer Synchronisierung hinzu 2026-04-21 19:35:55 +02:00
0b7d20d946 Fix materialComp und PWA DEVOptions
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m2s
2026-04-15 10:38:50 +02:00
849e24092e Added Teams
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 26s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
Minor Rework of Plantafel
2026-04-14 21:17:05 +02:00
6fcaf3f65c Fix Export Date Select
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m52s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
2026-04-08 20:24:33 +02:00
dce0046e63 Fix Ausgangsbeleg kopieren 2026-04-08 20:24:15 +02:00
02b5769049 Fix BWA Calc 2026-04-08 18:52:16 +02:00
f125617af0 Kundenportal arbeiten 2026-04-08 18:52:04 +02:00
d9e5df07bf serialinvoice fix
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 1m3s
2026-04-01 20:32:16 +02:00
7996c746c3 Add External Link
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m3s
Fix Plantafel
2026-03-27 21:41:20 +01:00
f679eb3624 Fix Serial invoice
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 2m58s
2026-03-26 23:00:56 +01:00
7ad44544cf Fix #119
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m36s
Build and Push Docker Images / build-frontend (push) Successful in 1m5s
Fix #153
2026-03-25 17:20:47 +01:00
669bcd93ab Tidying in TenantDropdown.vue 2026-03-25 17:14:15 +01:00
aee45e29fd Added Abschreibungen 2026-03-25 17:14:03 +01:00
42e0d7b35e Added Abschreibungen 2026-03-25 17:13:59 +01:00
f6c9875320 Fix #144
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m40s
Build and Push Docker Images / build-frontend (push) Successful in 1m2s
2026-03-25 16:04:17 +01:00
05f3b678c4 Fix for Incoming Invoices 2026-03-25 16:03:54 +01:00
eb718021fd Added Kostenschätzung und Packschein
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 22s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 15:38:28 +01:00
01b4d0f973 Fix for missing Phases
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 15:19:33 +01:00
c29494dc0d redone admin
added branches
2026-03-25 14:59:44 +01:00
809a37a410 fix workflows 2026-03-25 14:59:32 +01:00
232e3f3260 Fix copy created document modal 2026-03-25 14:59:19 +01:00
b2657f5d52 Fix Worklow Form 2026-03-25 14:58:55 +01:00
cee0e1fa7d Fix Phases
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 08:02:24 +01:00
7dea2de7f3 Fix unit select
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 13s
Build and Push Docker Images / build-frontend (push) Successful in 58s
2026-03-23 14:11:40 +01:00
4db753d34a Fix Banking and Profiles
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-23 08:29:54 +01:00
e0e99ba6f5 Fix #146 2026-03-23 08:26:21 +01:00
ace2213cc4 Fix #145 2026-03-23 08:16:44 +01:00
7e6c5cc189 Fix Changelog
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 21s
Build and Push Docker Images / build-frontend (push) Successful in 14s
2026-03-22 22:14:17 +01:00
7c644c941a fix #141
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 57s
fix #142
2026-03-22 22:10:41 +01:00
11a242d70d 4. Zwischenstand
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m0s
2026-03-22 17:43:41 +01:00
9f665fc3b8 3. Zwischenstand 2026-03-22 13:53:29 +01:00
243 changed files with 69204 additions and 3479 deletions

103
.env.example Normal file
View File

@@ -0,0 +1,103 @@
# FEDEO Selfhosting
DOMAIN=app.example.com
CONTACT_EMAIL=admin@deine-domain.de
DB_NAME=fedeo
DB_USER=fedeo
DB_PASSWORD=change-this-db-password
DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo
MINIO_ROOT_USER=fedeo-minio
MINIO_ROOT_PASSWORD=change-this-minio-password
MINIO_BUCKET=fedeo
HOST=0.0.0.0
PORT=3100
FEDEO_RUN_MIGRATIONS=true
COOKIE_SECRET=change-this-cookie-secret
JWT_SECRET=change-this-jwt-secret
ENCRYPTION_KEY=change-this-encryption-key
MAILER_SMTP_HOST=smtp.example.com
MAILER_SMTP_PORT=587
MAILER_SMTP_SSL=false
MAILER_SMTP_USER=mailer@example.com
MAILER_SMTP_PASS=change-this-mail-password
MAILER_FROM=FEDEO <no-reply@example.com>
# Desktop Push per Web Push. Schlüssel können mit
# `npx web-push generate-vapid-keys` erzeugt werden.
WEB_PUSH_PUBLIC_KEY=replace-this-web-push-public-key
WEB_PUSH_PRIVATE_KEY=replace-this-web-push-private-key
WEB_PUSH_SUBJECT=mailto:admin@example.com
S3_ENDPOINT=http://minio:9000
S3_REGION=eu-central-1
S3_ACCESS_KEY=fedeo-minio
S3_SECRET_KEY=change-this-minio-password
S3_BUCKET=fedeo
M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend
GOCARDLESS_BASE_URL=https://api.gocardless.com
GOCARDLESS_SECRET_ID=replace-this
GOCARDLESS_SECRET_KEY=replace-this
DOKUBOX_IMAP_HOST=imap.example.com
DOKUBOX_IMAP_PORT=993
DOKUBOX_IMAP_SECURE=true
DOKUBOX_IMAP_USER=dokubox@example.com
DOKUBOX_IMAP_PASSWORD=change-this-imap-password
OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
# FEDEO Matrix-Kommunikation
#
# Diese Werte werden von docker-compose.yml gelesen, wenn das Profil "matrix"
# genutzt wird. Für produktive Systeme müssen alle Geheimnisse ersetzt werden.
MATRIX_SERVER_NAME=fedeo.de
MATRIX_HOMESERVER_HOST=matrix.fedeo.de
MATRIX_RTC_HOST=call.fedeo.de
MATRIX_TURN_HOST=turn.fedeo.de
MATRIX_POSTGRES_DB=synapse
MATRIX_POSTGRES_USER=synapse
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
LIVEKIT_KEY=fedeo-livekit
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
# Lokale Matrix-Entwicklung
MATRIX_DEV_SYNAPSE_PORT=8008
MATRIX_DEV_ELEMENT_PORT=8080
MATRIX_DEV_RTC_JWT_PORT=8081
MATRIX_DEV_LIVEKIT_PORT=7880
MATRIX_DEV_LIVEKIT_TCP_PORT=7881
MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000
MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100
MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1
MATRIX_DEV_TURN_PORT=3478
MATRIX_DEV_TURN_MIN_PORT=49160
MATRIX_DEV_TURN_MAX_PORT=49200
# Backend-Integration gegen den lokalen Matrix-Stack
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_RTC_JWT_URL=http://localhost:8081
MATRIX_LIVEKIT_URL=ws://localhost:7880
MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080

View File

@@ -1,5 +1,5 @@
name: Build and Push Docker Images
run-name: Build Backend & Frontend by @${{ github.actor }}
run-name: Build Backend, Frontend & Docs by @${{ github.actor }}
on: [push]
@@ -8,12 +8,38 @@ env:
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
# Beispiel: gitea.deine-domain.de
REGISTRY_HOST: git.federspiel.tech
# Der Name des Repos (z.B. user/repo)
IMAGE_NAME: ${{ github.repository }}
# Der Name des Repos (z.B. user/repo).
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
IMAGE_NAME: flfeders/fedeo
ACTOR: flfeders
jobs:
# verify-docs-sync:
# runs-on: ubuntu-latest
# steps:
# - name: Check out repository code
# uses: actions/checkout@v3
#
# - name: Prüfe Node-Version
# uses: actions/setup-node@v4
# with:
# node-version: 20
#
# - name: Synchronisiere Funktionsdokumentation
# run: node docs/scripts/sync-funktionsdoku.mjs
#
# - name: Breche ab, wenn Doku nicht aktuell committed ist
# run: |
# if [ -n "$(git status --porcelain docs/)" ]; then
# echo "Die generierte Dokumentation ist nicht aktuell."
# echo "Bitte lokal ausführen: node docs/scripts/sync-funktionsdoku.mjs"
# echo "Danach die Änderungen committen."
# git status --short docs/
# exit 1
# fi
build-backend:
#needs: verify-docs-sync
runs-on: ubuntu-latest
steps:
- name: Check out repository code
@@ -46,6 +72,7 @@ jobs:
labels: ${{ steps.meta-backend.outputs.labels }}
build-frontend:
#needs: verify-docs-sync
runs-on: ubuntu-latest
steps:
- name: Check out repository code
@@ -74,4 +101,37 @@ jobs:
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
labels: ${{ steps.meta-frontend.outputs.labels }}
build-docs:
needs: verify-docs-sync
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Log in to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ env.ACTOR }}
password: ${{ vars.CI_TOKEN }}
- name: Extract metadata (tags, labels) for Docs
id: meta-docs
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/docs
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docs
uses: docker/build-push-action@v4
with:
context: .
file: ./docs-site/Dockerfile
push: true
tags: ${{ steps.meta-docs.outputs.tags }}
labels: ${{ steps.meta-docs.outputs.labels }}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
# Lokale Runtime-Daten und generierte Konfigurationen
matrix/postgres/
matrix/synapse/
matrix/dev/postgres/
matrix/dev/synapse/

View File

@@ -89,7 +89,7 @@ Wenn du MinIO verwendest, setze zusatzlich:
## Deploy-Struktur
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Selfhost-Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
Beispiel:
@@ -102,7 +102,7 @@ Die Verzeichnisstruktur sollte dann mindestens so aussehen:
```text
/opt/fedeo/
docker-compose.yml
docker-compose.selfhost.yml
.env
backend/
frontend/
@@ -124,13 +124,21 @@ touch /opt/fedeo/traefik/letsencrypt/acme.json
chmod 600 /opt/fedeo/traefik/letsencrypt/acme.json
```
Als Startpunkt kannst du die Beispielumgebung kopieren:
```bash
cp .env.example .env
```
Ersetze anschließend alle Platzhalter und passe mindestens `DOMAIN`, `CONTACT_EMAIL`, Datenbank-, Secret-, SMTP- und S3-Werte an.
## Beispiel `.env`
Diese Datei liegt neben der `docker-compose.yml`:
```env
DOMAIN=app.example.com
CONTACT_EMAIL=admin@example.com
CONTACT_EMAIL=admin@deine-domain.de
DB_NAME=fedeo
DB_USER=fedeo
@@ -176,11 +184,22 @@ OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
```
## Vollstandiges Docker Compose mit optionaler S3-MinIO-Option
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
Hinweis: Der Stack unten startet MinIO standardmassig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
## Docker Compose mit optionaler S3-MinIO-Option
Die Selfhost-Konfiguration liegt in `docker-compose.selfhost.yml`. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest.
```yaml
services:
@@ -316,6 +335,7 @@ services:
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
- traefik.docker.network=fedeo_web
networks:
- web
- internal
@@ -337,13 +357,16 @@ services:
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
- traefik.docker.network=fedeo_web
networks:
- web
networks:
web:
name: fedeo_web
driver: bridge
internal:
name: fedeo_internal
driver: bridge
```
@@ -372,16 +395,22 @@ Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit M
Im Deploy-Verzeichnis:
```bash
docker compose build
docker compose up -d
docker compose -f docker-compose.selfhost.yml build
docker compose -f docker-compose.selfhost.yml up -d
```
Danach Status prufen:
```bash
docker compose ps
docker compose logs -f traefik
docker compose logs -f backend
docker compose -f docker-compose.selfhost.yml ps
docker compose -f docker-compose.selfhost.yml logs -f traefik
docker compose -f docker-compose.selfhost.yml logs -f backend
```
Wenn du Migrationen manuell ausführen möchtest:
```bash
docker compose -f docker-compose.selfhost.yml run --rm backend npm run migrate
```
## Funktionsprufung
@@ -398,16 +427,20 @@ Erwartung:
- Frontend liefert `200` oder `302`
- Backend liefert JSON wie `{"status":"ok"}`
Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` anmelden. Die Mandantensperre wird über `locked` gesteuert; `hasActiveLicense` wird nicht mehr für den Selfhost-Zugriff ausgewertet.
## Updates
Bei neuen Versionen:
```bash
git pull
docker compose build
docker compose up -d
docker compose -f docker-compose.selfhost.yml build
docker compose -f docker-compose.selfhost.yml up -d
```
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden.
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
## Backup-Empfehlung

View File

@@ -12,8 +12,10 @@ RUN apt-get update \
# Package-Dateien
COPY package*.json ./
# Dev + Prod Dependencies (für TS-Build nötig)
RUN npm install
# Dev + Prod Dependencies (für TS-Build nötig).
# Sharp benötigt im Linux-Container native optionale Pakete, auch wenn das Lockfile auf macOS erzeugt wurde.
RUN npm install --include=optional \
&& npm install --include=optional --os=linux --cpu=x64 sharp
# Restlicher Sourcecode
COPY . .
@@ -24,5 +26,5 @@ RUN npm run build
# Port freigeben
EXPOSE 3100
# Start der App
CMD ["node", "dist/src/index.js"]
# Migrationen ausführen und App starten
CMD ["sh", "./docker-entrypoint.sh"]

View File

@@ -6,10 +6,16 @@ import {secrets} from "../src/utils/secrets";
console.log("[DB INIT] 1. Suche Connection String...");
const fallbackConnectionString = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
// Checken woher die URL kommt
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL;
if (connectionString) {
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
if (process.env.DATABASE_URL) {
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
} else if (secrets.DATABASE_URL) {
console.log("[DB INIT] -> Gefunden in secrets.DATABASE_URL");
} else if (connectionString) {
console.log("[DB INIT] -> Nutze Fallback aus dem Projekt");
} else {
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
}
@@ -24,4 +30,4 @@ pool.query('SELECT NOW()')
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
export const db = drizzle(pool, { schema });
export const db = drizzle(pool, { schema });

View File

@@ -14,26 +14,6 @@ CREATE TABLE "m2m_api_keys" (
CONSTRAINT "m2m_api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
CREATE TABLE "staff_time_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"actor_type" text NOT NULL,
"actor_user_id" uuid,
"event_time" timestamp with time zone NOT NULL,
"event_type" text NOT NULL,
"source" text NOT NULL,
"invalidates_event_id" uuid,
"related_event_id" uuid,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "time_events_actor_user_check" CHECK (
(actor_type = 'system' AND actor_user_id IS NULL)
OR
(actor_type = 'user' AND actor_user_id IS NOT NULL)
)
);
--> statement-breakpoint
CREATE TABLE "serialtypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "serialtypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
@@ -89,20 +69,15 @@ CREATE TABLE "wiki_pages" (
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "time_events" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "time_events" CASCADE;--> statement-breakpoint
ALTER TABLE "projects" ALTER COLUMN "active_phase" SET DEFAULT 'Erstkontakt';--> statement-breakpoint
ALTER TABLE "createddocuments" ADD COLUMN "serialexecution" uuid;--> statement-breakpoint
ALTER TABLE "devices" ADD COLUMN "last_seen" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "devices" ADD COLUMN "last_debug_info" jsonb;--> statement-breakpoint
ALTER TABLE "files" ADD COLUMN "size" bigint;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD COLUMN "related_event_id" uuid;--> statement-breakpoint
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_actor_user_id_auth_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_invalidates_event_id_staff_time_events_id_fk" FOREIGN KEY ("invalidates_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_related_event_id_staff_time_events_id_fk" FOREIGN KEY ("related_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
@@ -113,11 +88,8 @@ ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_tenant_id_tenants_id_fk" FOR
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_parent_id_wiki_pages_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_time_events_tenant_user_time" ON "staff_time_events" USING btree ("tenant_id","user_id","event_time");--> statement-breakpoint
CREATE INDEX "idx_time_events_created_at" ON "staff_time_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_time_events_invalidates" ON "staff_time_events" USING btree ("invalidates_event_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_tenant_idx" ON "wiki_pages" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_parent_idx" ON "wiki_pages" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_entity_int_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_entity_uuid_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_uuid");--> statement-breakpoint
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_serialexecution_serial_executions_id_fk" FOREIGN KEY ("serialexecution") REFERENCES "public"."serial_executions"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_serialexecution_serial_executions_id_fk" FOREIGN KEY ("serialexecution") REFERENCES "public"."serial_executions"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -1,108 +1 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"billingInterval" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerinventoryitems" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"customerspace" bigint,
"customerInventoryId" text NOT NULL,
"serialNumber" text,
"quantity" bigint DEFAULT 0 NOT NULL,
"manufacturer" text,
"manufacturerNumber" text,
"purchaseDate" date,
"purchasePrice" double precision DEFAULT 0,
"currentValue" double precision,
"product" bigint,
"vendor" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerspaces" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"spaceNumber" text NOT NULL,
"parentSpace" bigint,
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "entitybankaccounts" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"iban_encrypted" jsonb NOT NULL,
"bic_encrypted" jsonb NOT NULL,
"bank_name_encrypted" jsonb NOT NULL,
"description" text,
"updated_at" timestamp with time zone,
"updated_by" uuid,
"archived" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberrelations" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"type" text NOT NULL,
"billingInterval" text NOT NULL,
"billingAmount" double precision DEFAULT 0 NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;
-- Absichtlich leer: Die Objekte aus dieser generierten Migration existieren bereits in früheren Migrationen.

View File

@@ -0,0 +1,37 @@
CREATE TABLE "branches" (
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"number" text,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "costcentres" ADD COLUMN "branch" bigint;
--> statement-breakpoint
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD COLUMN "branch_id" bigint;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD CONSTRAINT "auth_profiles_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
CREATE TABLE "auth_profile_branches" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"profile_id" uuid NOT NULL,
"branch_id" bigint NOT NULL,
"created_by" uuid,
CONSTRAINT "auth_profile_branches_profile_id_branch_id_pk" PRIMARY KEY("profile_id","branch_id")
);
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "statementallocations"
ADD COLUMN "booking_mode" text DEFAULT 'expense' NOT NULL,
ADD COLUMN "depreciation_months" integer,
ADD COLUMN "depreciation_start_date" text,
ADD COLUMN "depreciation_label" text,
ADD COLUMN "depreciation_group" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "statementallocations"
ADD COLUMN "depreciation_method" text,
ADD COLUMN "residual_value" double precision;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "products"
ADD COLUMN "supplierLink" text;

View File

@@ -0,0 +1,31 @@
CREATE TABLE "teams" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "teams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"branch" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "auth_profile_teams" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"profile_id" uuid NOT NULL,
"team_id" bigint NOT NULL,
"created_by" uuid,
CONSTRAINT "auth_profile_teams_profile_id_team_id_pk" PRIMARY KEY("profile_id","team_id")
);
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "events" ADD COLUMN "quick" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1,10 @@
ALTER TABLE "statementallocations" ALTER COLUMN "bs_id" DROP NOT NULL;
ALTER TABLE "statementallocations" ADD COLUMN "manual_booking_date" text;
ALTER TABLE "statementallocations" ADD COLUMN "contra_account" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_ownaccount" uuid;
ALTER TABLE "statementallocations" ADD COLUMN "contra_customer" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_vendor" bigint;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_account_accounts_id_fk" FOREIGN KEY ("contra_account") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_ownaccount_ownaccounts_id_fk" FOREIGN KEY ("contra_ownaccount") REFERENCES "public"."ownaccounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_customer_customers_id_fk" FOREIGN KEY ("contra_customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_vendor_vendors_id_fk" FOREIGN KEY ("contra_vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "datev_tax_key" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "manual_invoice_side" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid;
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,8 @@
ALTER TABLE "events" ADD COLUMN "color" text;
UPDATE "events" AS e
SET "color" = COALESCE(t."calendarConfig"->'quickEntry'->>'color', '#2563eb')
FROM "tenants" AS t
WHERE e."tenant" = t."id"
AND e."quick" = true
AND e."color" IS NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN IF NOT EXISTS "availability_note" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "historyitems" ADD COLUMN "contract" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_contract_contracts_id_fk" FOREIGN KEY ("contract") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "contracts" ADD COLUMN "allowedContracttypes" jsonb DEFAULT '[]'::jsonb NOT NULL;

View File

@@ -0,0 +1,53 @@
CREATE TABLE "outgoingsepamandates" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "outgoingsepamandates_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"bankaccount" bigint NOT NULL,
"reference" text NOT NULL,
"status" text DEFAULT 'Entwurf' NOT NULL,
"mandate_type" text DEFAULT 'CORE' NOT NULL,
"sequence_type" text DEFAULT 'RCUR' NOT NULL,
"signed_at" timestamp with time zone,
"valid_from" timestamp with time zone,
"valid_until" timestamp with time zone,
"default_mandate" boolean DEFAULT false NOT NULL,
"notes" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_bankaccount_entitybankaccounts_id_fk" FOREIGN KEY ("bankaccount") REFERENCES "public"."entitybankaccounts"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "outgoingsepamandate" bigint;
--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "createddocuments" ADD COLUMN "outgoingsepamandate" bigint;
--> statement-breakpoint
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "outgoingsepamandate" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"costEstimates":{"prefix":"KS-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"deliveryNotes":{"prefix":"LS-","suffix":"","nextNumber":1000},"packingSlips":{"prefix":"PS-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000},"outgoingsepamandates":{"prefix":"SEPA-","suffix":"","nextNumber":1000}}'::jsonb;
--> statement-breakpoint
UPDATE "tenants"
SET "numberRanges" = COALESCE("numberRanges", '{}'::jsonb) || jsonb_build_object(
'outgoingsepamandates',
COALESCE("numberRanges"->'outgoingsepamandates', '{"prefix":"SEPA-","suffix":"","nextNumber":1000}'::jsonb)
);
--> statement-breakpoint
UPDATE "tenants"
SET "features" = COALESCE("features", '{}'::jsonb) || jsonb_build_object(
'outgoingsepamandates',
COALESCE("features"->'outgoingsepamandates', 'true'::jsonb)
);

View File

@@ -0,0 +1,43 @@
CREATE TABLE "communication_rooms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"key" text NOT NULL,
"name" text NOT NULL,
"topic" text,
"type" text DEFAULT 'room' NOT NULL,
"entity_type" text,
"entity_id" bigint,
"entity_uuid" uuid,
"matrix_room_id" text,
"matrix_alias" text,
"parent_space_room_id" text,
"archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
CREATE UNIQUE INDEX "communication_rooms_tenant_key_idx"
ON "communication_rooms" USING btree ("tenant_id", "key");
CREATE INDEX "communication_rooms_tenant_idx"
ON "communication_rooms" USING btree ("tenant_id");
CREATE INDEX "communication_rooms_entity_idx"
ON "communication_rooms" USING btree ("tenant_id", "entity_type", "entity_id", "entity_uuid");

View File

@@ -0,0 +1,5 @@
ALTER TABLE "events" ADD COLUMN "state" text DEFAULT 'Final' NOT NULL;
UPDATE "events"
SET "state" = 'Final'
WHERE "state" IS NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "events" ADD COLUMN "repeatInterval" text DEFAULT 'Keine Wiederholung' NOT NULL;

View File

@@ -0,0 +1,46 @@
CREATE TABLE "notification_push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"user_agent" text,
"device_label" text,
"meta" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"disabled_at" timestamp with time zone,
CONSTRAINT "notification_push_subscriptions_endpoint_key" UNIQUE("endpoint")
);
ALTER TABLE "notification_push_subscriptions"
ADD CONSTRAINT "notification_push_subscriptions_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE cascade;
ALTER TABLE "notification_push_subscriptions"
ADD CONSTRAINT "notification_push_subscriptions_user_id_auth_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id")
ON DELETE cascade ON UPDATE cascade;
INSERT INTO "notifications_event_types" (
"event_key",
"display_name",
"description",
"category",
"severity",
"allowed_channels"
) VALUES
('system.test_push', 'Test-Push', 'Testet Desktop-Benachrichtigungen für den angemeldeten Nutzer.', 'system', 'info', '["inapp", "push"]'::jsonb),
('communication.message.new', 'Neue Chatnachricht', 'Benachrichtigt über relevante neue Chatnachrichten.', 'communication', 'info', '["inapp", "push"]'::jsonb),
('communication.call.started', 'Besprechung gestartet', 'Benachrichtigt Raumteilnehmer über gestartete Audio- oder Videoanrufe.', 'communication', 'info', '["inapp", "push"]'::jsonb),
('communication.call.missed', 'Verpasster Anruf', 'Benachrichtigt über verpasste Besprechungen.', 'communication', 'warning', '["inapp", "push"]'::jsonb),
('communication.room.invited', 'Raumeinladung', 'Benachrichtigt über Einladungen in Kommunikationsräume.', 'communication', 'info', '["inapp", "push"]'::jsonb)
ON CONFLICT ("event_key") DO UPDATE SET
"display_name" = EXCLUDED."display_name",
"description" = EXCLUDED."description",
"category" = EXCLUDED."category",
"severity" = EXCLUDED."severity",
"allowed_channels" = EXCLUDED."allowed_channels",
"is_active" = true;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "filetags" ADD COLUMN "isSystemUsed" boolean DEFAULT false NOT NULL;
UPDATE "filetags"
SET "isSystemUsed" = true
WHERE COALESCE("createddocumenttype", '') <> ''
OR COALESCE("incomingDocumentType", '') <> '';

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN "calendar_subscription_token" text;

File diff suppressed because it is too large Load Diff

View File

@@ -138,30 +138,163 @@
{
"idx": 19,
"version": "7",
"when": 1773489600000,
"tag": "0019_custom_surcharge_percentage_decimal",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773572400000,
"tag": "0020_file_extracted_text",
"breakpoints": true
},
{
"idx": 20,
"idx": 21,
"version": "7",
"when": 1773835200000,
"tag": "0021_admin_user_flag",
"breakpoints": true
},
{
"idx": 21,
"idx": 22,
"version": "7",
"when": 1773925200000,
"tag": "0022_task_dependencies",
"breakpoints": true
},
{
"idx": 22,
"idx": 23,
"version": "7",
"when": 1774080000000,
"tag": "0023_tax_evaluation_period",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774393200000,
"tag": "0024_tenant_branches",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1774393201000,
"tag": "0025_statementallocation_depreciation",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1774393202000,
"tag": "0026_statementallocation_depreciation_method",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1774602000000,
"tag": "0027_product_supplier_link",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1776124800000,
"tag": "0028_teams",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1776211200000,
"tag": "0029_events_quick",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1776297600000,
"tag": "0030_manual_statementallocations",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1776298200000,
"tag": "0031_manual_statementallocations_tax_key",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1776298800000,
"tag": "0032_manual_statementallocations_invoice_side",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1777003200000,
"tag": "0033_costcentres_parent",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1777420800000,
"tag": "0034_events_color",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1778191200000,
"tag": "0035_contract_history",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1778194800000,
"tag": "0036_allowed_contracttypes",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1778840100000,
"tag": "0037_outgoing_sepa_mandates",
"breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1779158400000,
"tag": "0038_events_state",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1779840000000,
"tag": "0039_events_repeat_interval",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1779141600000,
"tag": "0040_filetag_system_types",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1780149600000,
"tag": "0041_profile_calendar_subscription",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
import { authProfiles } from "./auth_profiles"
import { branches } from "./branches"
import { authUsers } from "./auth_users"
export const authProfileBranches = pgTable(
"auth_profile_branches",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
profile_id: uuid("profile_id")
.notNull()
.references(() => authProfiles.id, { onDelete: "cascade" }),
branch_id: bigint("branch_id", { mode: "number" })
.notNull()
.references(() => branches.id, { onDelete: "cascade" }),
created_by: uuid("created_by").references(() => authUsers.id),
},
(table) => ({
primaryKey: [table.profile_id, table.branch_id],
})
)
export type AuthProfileBranch = typeof authProfileBranches.$inferSelect
export type NewAuthProfileBranch = typeof authProfileBranches.$inferInsert

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
import { authProfiles } from "./auth_profiles"
import { teams } from "./teams"
import { authUsers } from "./auth_users"
export const authProfileTeams = pgTable(
"auth_profile_teams",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
profile_id: uuid("profile_id")
.notNull()
.references(() => authProfiles.id, { onDelete: "cascade" }),
team_id: bigint("team_id", { mode: "number" })
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
created_by: uuid("created_by").references(() => authUsers.id),
},
(table) => ({
primaryKey: [table.profile_id, table.team_id],
})
)
export type AuthProfileTeam = typeof authProfileTeams.$inferSelect
export type NewAuthProfileTeam = typeof authProfileTeams.$inferInsert

View File

@@ -10,6 +10,7 @@ import {
jsonb,
} from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const authProfiles = pgTable("auth_profiles", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", {
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
branch_id: bigint("branch_id", { mode: "number" }).references(() => branches.id),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
@@ -60,6 +63,7 @@ export const authProfiles = pgTable("auth_profiles", {
email: text("email"),
token_id: text("token_id"),
calendar_subscription_token: text("calendar_subscription_token"),
weekly_working_days: doublePrecision("weekly_working_days"),
@@ -71,6 +75,7 @@ export const authProfiles = pgTable("auth_profiles", {
contract_type: text("contract_type"),
position: text("position"),
qualification: text("qualification"),
availability_note: text("availability_note"),
address_street: text("address_street"),
address_zip: text("address_zip"),

View File

@@ -0,0 +1,37 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const branches = pgTable("branches", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
number: text("number"),
description: text("description"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Branch = typeof branches.$inferSelect
export type NewBranch = typeof branches.$inferInsert

View File

@@ -0,0 +1,57 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
boolean,
uniqueIndex,
index,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const communicationRooms = pgTable(
"communication_rooms",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
key: text("key").notNull(),
name: text("name").notNull(),
topic: text("topic"),
type: text("type").notNull().default("room"),
entityType: text("entity_type"),
entityId: bigint("entity_id", { mode: "number" }),
entityUuid: uuid("entity_uuid"),
matrixRoomId: text("matrix_room_id"),
matrixAlias: text("matrix_alias"),
parentSpaceRoomId: text("parent_space_room_id"),
archived: boolean("archived").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => authUsers.id),
updatedBy: uuid("updated_by").references(() => authUsers.id),
},
(table) => ({
tenantKeyIdx: uniqueIndex("communication_rooms_tenant_key_idx")
.on(table.tenantId, table.key),
tenantIdx: index("communication_rooms_tenant_idx")
.on(table.tenantId),
entityIdx: index("communication_rooms_entity_idx")
.on(table.tenantId, table.entityType, table.entityId, table.entityUuid),
})
)
export type CommunicationRoom = typeof communicationRooms.$inferSelect
export type NewCommunicationRoom = typeof communicationRooms.$inferInsert

View File

@@ -13,6 +13,7 @@ import { customers } from "./customers"
import { contacts } from "./contacts"
import { contracttypes } from "./contracttypes"
import { authUsers } from "./auth_users"
import { outgoingsepamandates } from "./outgoingsepamandates"
export const contracts = pgTable(
"contracts",
@@ -52,6 +53,7 @@ export const contracts = pgTable(
contracttype: bigint("contracttype", { mode: "number" }).references(
() => contracttypes.id
),
allowedContracttypes: jsonb("allowedContracttypes").notNull().default([]),
bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"),
@@ -59,6 +61,9 @@ export const contracts = pgTable(
bankingOwner: text("bankingOwner"),
sepaRef: text("sepaRef"),
sepaDate: timestamp("sepaDate", { withTimezone: true }),
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
() => outgoingsepamandates.id
),
paymentType: text("paymentType"),
billingInterval: text("billingInterval"),

View File

@@ -13,6 +13,7 @@ import { inventoryitems } from "./inventoryitems"
import { projects } from "./projects"
import { vehicles } from "./vehicles"
import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const costcentres = pgTable("costcentres", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -28,10 +29,14 @@ export const costcentres = pgTable("costcentres", {
number: text("number").notNull(),
name: text("name").notNull(),
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
project: bigint("project", { mode: "number" }).references(() => projects.id),
branch: bigint("branch", { mode: "number" }).references(() => branches.id),
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
() => inventoryitems.id
),

View File

@@ -19,6 +19,7 @@ import { projects } from "./projects"
import { plants } from "./plants"
import { authUsers } from "./auth_users"
import {serialExecutions} from "./serialexecutions";
import { outgoingsepamandates } from "./outgoingsepamandates"
export const createddocuments = pgTable("createddocuments", {
id: bigint("id", { mode: "number" })
@@ -118,6 +119,10 @@ export const createddocuments = pgTable("createddocuments", {
() => contracts.id
),
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
() => outgoingsepamandates.id
),
serialexecution: uuid("serialexecution").references(() => serialExecutions.id)
})

View File

@@ -31,6 +31,10 @@ export const events = pgTable(
endDate: timestamp("endDate", { withTimezone: true }),
eventtype: text("eventtype").default("Umsetzung"),
quick: boolean("quick").notNull().default(false),
state: text("state").notNull().default("Final"),
color: text("color"),
repeatInterval: text("repeatInterval").notNull().default("Keine Wiederholung"),
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists

View File

@@ -26,6 +26,8 @@ export const filetags = pgTable("filetags", {
createdDocumentType: text("createddocumenttype").default(""),
incomingDocumentType: text("incomingDocumentType"),
isSystemUsed: boolean("isSystemUsed").notNull().default(false),
archived: boolean("archived").notNull().default(false),
})

View File

@@ -35,6 +35,8 @@ import { inventoryitemgroups } from "./inventoryitemgroups"
import { authUsers } from "./auth_users"
import {files} from "./files";
import { memberrelations } from "./memberrelations";
import { contracts } from "./contracts";
import { outgoingsepamandates } from "./outgoingsepamandates";
export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" })
@@ -52,6 +54,11 @@ export const historyitems = pgTable("historyitems", {
{ onDelete: "cascade" }
),
contract: bigint("contract", { mode: "number" }).references(
() => contracts.id,
{ onDelete: "cascade" }
),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
@@ -108,6 +115,11 @@ export const historyitems = pgTable("historyitems", {
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
() => outgoingsepamandates.id,
{ onDelete: "cascade" }
),
config: jsonb("config"),
projecttype: bigint("projecttype", { mode: "number" }).references(

View File

@@ -1,5 +1,7 @@
export * from "./accounts"
export * from "./auth_profiles"
export * from "./auth_profile_branches"
export * from "./auth_profile_teams"
export * from "./auth_role_permisssions"
export * from "./auth_roles"
export * from "./auth_tenant_users"
@@ -8,9 +10,11 @@ export * from "./auth_users"
export * from "./bankaccounts"
export * from "./bankrequisitions"
export * from "./bankstatements"
export * from "./branches"
export * from "./checkexecutions"
export * from "./checks"
export * from "./citys"
export * from "./communication_rooms"
export * from "./contacts"
export * from "./contracts"
export * from "./contracttypes"
@@ -53,7 +57,9 @@ export * from "./notifications_event_types"
export * from "./notifications_items"
export * from "./notifications_preferences"
export * from "./notifications_preferences_defaults"
export * from "./notification_push_subscriptions"
export * from "./ownaccounts"
export * from "./outgoingsepamandates"
export * from "./plants"
export * from "./productcategories"
export * from "./products"
@@ -67,6 +73,7 @@ export * from "./staff_time_entry_connects"
export * from "./staff_zeitstromtimestamps"
export * from "./statementallocations"
export * from "./tasks"
export * from "./teams"
export * from "./taxtypes"
export * from "./tenants"
export * from "./texttemplates"

View File

@@ -0,0 +1,50 @@
import {
pgTable,
uuid,
bigint,
text,
jsonb,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const notificationPushSubscriptions = pgTable(
"notification_push_subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
endpoint: text("endpoint").notNull(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
userAgent: text("user_agent"),
deviceLabel: text("device_label"),
meta: jsonb("meta"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
.notNull()
.defaultNow(),
disabledAt: timestamp("disabled_at", { withTimezone: true }),
},
(table) => ({
uniqueEndpoint: uniqueIndex("notification_push_subscriptions_endpoint_key").on(table.endpoint),
}),
)
export type NotificationPushSubscription =
typeof notificationPushSubscriptions.$inferSelect
export type NewNotificationPushSubscription =
typeof notificationPushSubscriptions.$inferInsert

View File

@@ -0,0 +1,61 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { entitybankaccounts } from "./entitybankaccounts"
import { authUsers } from "./auth_users"
export const outgoingsepamandates = pgTable("outgoingsepamandates", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
customer: bigint("customer", { mode: "number" })
.notNull()
.references(() => customers.id),
bankaccount: bigint("bankaccount", { mode: "number" })
.notNull()
.references(() => entitybankaccounts.id),
reference: text("reference").notNull(),
status: text("status").notNull().default("Entwurf"),
mandateType: text("mandate_type").notNull().default("CORE"),
sequenceType: text("sequence_type").notNull().default("RCUR"),
signedAt: timestamp("signed_at", { withTimezone: true }),
validFrom: timestamp("valid_from", { withTimezone: true }),
validUntil: timestamp("valid_until", { withTimezone: true }),
defaultMandate: boolean("default_mandate").notNull().default(false),
notes: text("notes"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type OutgoingSepaMandate = typeof outgoingsepamandates.$inferSelect
export type NewOutgoingSepaMandate = typeof outgoingsepamandates.$inferInsert

View File

@@ -50,6 +50,7 @@ export const products = pgTable("products", {
vendor_allocation: jsonb("vendorAllocation").default([]),
article_number: text("articleNumber"),
supplier_link: text("supplierLink"),
barcodes: text("barcodes").array().notNull().default([]),

View File

@@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", {
id: uuid("id").primaryKey().defaultRandom(),
// foreign keys
bankstatement: integer("bs_id")
.notNull()
.references(() => bankstatements.id),
bankstatement: integer("bs_id").references(() => bankstatements.id),
createddocument: integer("cd_id").references(() => createddocuments.id),
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
incominginvoice: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id
),
manualInvoiceSide: text("manual_invoice_side"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
@@ -43,20 +42,43 @@ export const statementallocations = pgTable("statementallocations", {
() => accounts.id
),
contraAccount: bigint("contra_account", { mode: "number" }).references(
() => accounts.id
),
created_at: timestamp("created_at", {
withTimezone: false,
}).defaultNow(),
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
description: text("description"),
manualBookingDate: text("manual_booking_date"),
datevTaxKey: text("datev_tax_key"),
bookingMode: text("booking_mode").notNull().default("expense"),
depreciationMonths: integer("depreciation_months"),
depreciationStartDate: text("depreciation_start_date"),
depreciationMethod: text("depreciation_method"),
depreciationLabel: text("depreciation_label"),
depreciationGroup: text("depreciation_group"),
residualValue: doublePrecision("residual_value"),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
contraCustomer: bigint("contra_customer", { mode: "number" }).references(
() => customers.id
),
contraVendor: bigint("contra_vendor", { mode: "number" }).references(() => vendors.id),
updated_at: timestamp("updated_at", { withTimezone: true }),
updated_by: uuid("updated_by").references(() => authUsers.id),

View File

@@ -0,0 +1,40 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { branches } from "./branches"
export const teams = pgTable("teams", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
description: text("description"),
branch: bigint("branch", { mode: "number" })
.references(() => branches.id),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Team = typeof teams.$inferSelect
export type NewTeam = typeof teams.$inferInsert

View File

@@ -91,7 +91,10 @@ export const tenants = pgTable(
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
outgoingsepamandates: true,
costcentres: true,
branches: true,
teams: true,
accounts: true,
ownaccounts: true,
banking: true,
@@ -127,14 +130,18 @@ export const tenants = pgTable(
customers: { prefix: "", suffix: "", nextNumber: 10000 },
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
costEstimates: { prefix: "KS-", suffix: "", nextNumber: 1000 },
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
deliveryNotes: { prefix: "LS-", suffix: "", nextNumber: 1000 },
packingSlips: { prefix: "PS-", suffix: "", nextNumber: 1000 },
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
customerinventoryitems: { prefix: "KIA-", suffix: "", nextNumber: 1000 },
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
outgoingsepamandates: { prefix: "SEPA-", suffix: "", nextNumber: 1000 },
}),
accountChart: text("accountChart").notNull().default("skr03"),

View File

@@ -0,0 +1,7 @@
set -e
if [ "${FEDEO_RUN_MIGRATIONS:-true}" = "true" ]; then
npm run migrate
fi
exec node dist/src/index.js

View File

@@ -1,11 +1,14 @@
import "dotenv/config"
import { defineConfig } from "drizzle-kit"
import {secrets} from "./src/utils/secrets";
const fallbackDatabaseUrl = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
const databaseUrl = process.env.DATABASE_URL || fallbackDatabaseUrl
export default defineConfig({
dialect: "postgresql",
schema: "./db/schema",
out: "./db/migrations",
dbCredentials: {
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
url: databaseUrl,
},
})
})

10423
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"main": "index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"migrate": "tsx scripts/migrate.ts",
"fill": "ts-node src/webdav/fill-file-sizes.ts",
"dev:dav": "tsx watch src/webdav/server.ts",
"build": "tsc",
@@ -12,6 +13,7 @@
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
},
"repository": {
@@ -52,6 +54,7 @@
"pg": "^8.16.3",
"pngjs": "^7.0.0",
"sharp": "^0.34.5",
"web-push": "^3.6.7",
"webdav-server": "^2.6.2",
"xmlbuilder": "^15.1.1",
"zpl-image": "^0.2.0",
@@ -61,6 +64,7 @@
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.3.0",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.8",
"prisma": "^6.15.0",
"tsx": "^4.20.5",

View File

@@ -0,0 +1,589 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { execFileSync } from "node:child_process"
import { and, eq } from "drizzle-orm"
import {
authProfileBranches,
authProfiles,
authProfileTeams,
branches,
teams,
tenants,
} from "../db/schema"
type ImportRow = {
rowNumber: number
mitarbeiter: string
betrieb: string
anstellung: string
position: string
bereich: string
stundenMonat: number | null
urlaub: number | null
}
type CliOptions = {
workbookPath: string
tenantId: number
dryRun: boolean
defaultBranchId: number | null
branchMap: Record<string, number>
}
type ImportAction = "create" | "update"
function printHelp() {
console.log(`
Importiert die Excel-Datei "Mitarbeiterliste.xlsm" in auth_profiles.
Verwendung:
npm run profiles:import:mitarbeiterliste -- --tenant-id=12 --branch-map-file=./branch-map.json /pfad/zur/Mitarbeiterliste.xlsm
Optionen:
--tenant-id=ID Pflicht. Tenant-ID für den Import.
--branch-map='{"Name":1}' Optional. JSON-Mapping Betrieb -> branchId.
--branch-map-file=DATEI Optional. JSON-Datei mit Betrieb -> branchId.
--default-branch-id=ID Optional. Fallback-Branch-ID für nicht gemappte Betriebe.
--dry-run Führt keine Schreiboperationen aus.
--help Zeigt diese Hilfe an.
Beispiel branch-map.json:
{
"Strandcafé": 10,
"1848 Pütt": 11,
"Oceans11": 12,
"Winnys": 13
}
`.trim())
}
function parseArgs(argv: string[]): CliOptions | null {
const options: CliOptions = {
workbookPath: "/Users/florianfederspiel/Downloads/Mitarbeiterliste.xlsm",
tenantId: Number.NaN,
dryRun: false,
defaultBranchId: null,
branchMap: {},
}
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
return null
}
if (arg === "--dry-run") {
options.dryRun = true
continue
}
if (arg.startsWith("--tenant-id=")) {
options.tenantId = Number(arg.slice("--tenant-id=".length))
continue
}
if (arg.startsWith("--default-branch-id=")) {
options.defaultBranchId = Number(arg.slice("--default-branch-id=".length))
continue
}
if (arg.startsWith("--branch-map=")) {
options.branchMap = parseBranchMap(arg.slice("--branch-map=".length), "CLI")
continue
}
if (arg.startsWith("--branch-map-file=")) {
const branchMapPath = path.resolve(arg.slice("--branch-map-file=".length))
options.branchMap = parseBranchMap(fs.readFileSync(branchMapPath, "utf8"), branchMapPath)
continue
}
if (!arg.startsWith("--")) {
options.workbookPath = path.resolve(arg)
continue
}
throw new Error(`Unbekanntes Argument: ${arg}`)
}
if (!Number.isFinite(options.tenantId)) {
throw new Error("Bitte --tenant-id=... angeben.")
}
if (options.defaultBranchId != null && !Number.isFinite(options.defaultBranchId)) {
throw new Error("--default-branch-id muss numerisch sein.")
}
return options
}
function parseBranchMap(raw: string, sourceLabel: string): Record<string, number> {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error) {
throw new Error(`Branch-Mapping aus ${sourceLabel} konnte nicht gelesen werden: ${(error as Error).message}`)
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Branch-Mapping aus ${sourceLabel} muss ein JSON-Objekt sein.`)
}
const normalizedEntries = Object.entries(parsed).map(([key, value]) => {
const branchId = Number(value)
if (!Number.isFinite(branchId)) {
throw new Error(`Ungültige Branch-ID für "${key}" in ${sourceLabel}.`)
}
return [normalizeKey(key), branchId] as const
})
return Object.fromEntries(normalizedEntries)
}
function normalizeKey(value: string) {
return String(value || "")
.trim()
.replace(/\s+/g, " ")
.toLocaleLowerCase("de-DE")
}
function decodeXmlText(value: string) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
}
function getWorkbookXml(workbookPath: string, innerPath: string) {
return execFileSync("unzip", ["-p", workbookPath, innerPath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
})
}
function readSharedStrings(workbookPath: string) {
const xml = getWorkbookXml(workbookPath, "xl/sharedStrings.xml")
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) => {
const parts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) => decodeXmlText(part[1]))
return parts.join("")
})
}
function readSheetRows(workbookPath: string, sharedStrings: string[]) {
const sheetXml = getWorkbookXml(workbookPath, "xl/worksheets/sheet1.xml")
const rows: Record<string, string>[] = []
for (const rowMatch of sheetXml.matchAll(/<row\b[^>]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)) {
const cellMap: Record<string, string> = {}
const rowXml = rowMatch[2]
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attrs = cellMatch[1]
const cellXml = cellMatch[2]
const refMatch = attrs.match(/r="([A-Z]+)\d+"/)
if (!refMatch) continue
const column = refMatch[1]
const typeMatch = attrs.match(/t="([^"]+)"/)
const type = typeMatch?.[1] || ""
const valueMatch = cellXml.match(/<v>([\s\S]*?)<\/v>/)
const inlineTextMatch = cellXml.match(/<t\b[^>]*>([\s\S]*?)<\/t>/)
let value = ""
if (type === "s" && valueMatch) {
value = sharedStrings[Number(valueMatch[1])] || ""
} else if (inlineTextMatch) {
value = decodeXmlText(inlineTextMatch[1])
} else if (valueMatch) {
value = decodeXmlText(valueMatch[1])
}
cellMap[column] = value.trim()
}
rows.push(cellMap)
}
return rows
}
function parseNumber(value: string) {
const normalized = String(value || "").trim().replace(",", ".")
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
function parseWorkbook(workbookPath: string): ImportRow[] {
const sharedStrings = readSharedStrings(workbookPath)
const rows = readSheetRows(workbookPath, sharedStrings)
if (!rows.length) {
throw new Error("Die Arbeitsmappe enthält keine Zeilen.")
}
const header = rows[0]
if (header.A !== "Mitarbeiter" || header.B !== "Betrieb") {
throw new Error("Unerwartetes Format der Excel-Datei. Erwartet wurden die Spalten 'Mitarbeiter' und 'Betrieb'.")
}
return rows
.slice(1)
.map((row, index) => ({
rowNumber: index + 2,
mitarbeiter: row.A || "",
betrieb: row.B || "",
anstellung: row.C || "",
position: row.D || "",
bereich: row.E || "",
stundenMonat: parseNumber(row.F || ""),
urlaub: parseNumber(row.G || ""),
}))
.filter((row) => row.mitarbeiter)
}
function splitFullName(fullName: string) {
const parts = fullName.trim().split(/\s+/).filter(Boolean)
if (parts.length <= 1) {
return {
firstName: fullName.trim(),
lastName: "Unbekannt",
}
}
return {
firstName: parts.slice(0, -1).join(" "),
lastName: parts[parts.length - 1],
}
}
function toWeeklyHours(monthlyHours: number | null) {
if (monthlyHours == null) return null
return Math.round(((monthlyHours * 12) / 52) * 100) / 100
}
function formatValue(value: unknown) {
if (value == null) return "-"
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
function mapEmploymentCategory(value: string) {
const normalized = normalizeKey(value)
if (normalized === "aushilfe") return "Aushilfen"
if (normalized === "teilzeit" || normalized === "vollzeit") return "Festangestellte"
return null
}
function buildTeamName(bereich: string, anstellung: string) {
const employmentCategory = mapEmploymentCategory(anstellung)
const normalizedBereich = String(bereich || "").trim()
if (!normalizedBereich || !employmentCategory) return null
return `${normalizedBereich} ${employmentCategory}`
}
function collectFieldChanges(existing: any, nextPayload: Record<string, unknown>) {
if (!existing) return []
const changes: string[] = []
for (const [field, nextValue] of Object.entries(nextPayload)) {
const currentValue = existing[field]
const currentFormatted = formatValue(currentValue)
const nextFormatted = formatValue(nextValue)
if (currentFormatted !== nextFormatted) {
changes.push(`${field}: ${currentFormatted} -> ${nextFormatted}`)
}
}
return changes
}
async function validateTenantAndBranches(
db: any,
tenantId: number,
branchIds: number[]
) {
const [tenant] = await db
.select({ id: tenants.id, name: tenants.name })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
if (!tenant) {
throw new Error(`Tenant ${tenantId} wurde nicht gefunden.`)
}
const branchRows = await db
.select({ id: branches.id, name: branches.name })
.from(branches)
.where(eq(branches.tenant, tenantId))
const validBranchIds = new Set(branchRows.map((branch: any) => Number(branch.id)))
for (const branchId of branchIds) {
if (!validBranchIds.has(branchId)) {
throw new Error(`Branch-ID ${branchId} gehört nicht zum Tenant ${tenantId}.`)
}
}
return {
tenant,
branchRows,
}
}
async function loadTenantTeams(db: any, tenantId: number) {
const teamRows = await db
.select({
id: teams.id,
name: teams.name,
branch: teams.branch,
archived: teams.archived,
})
.from(teams)
.where(eq(teams.tenant, tenantId))
return new Map(
teamRows
.filter((team: any) => !team.archived)
.map((team: any) => [`${team.branch}::${normalizeKey(team.name)}`, team])
)
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (!options) {
printHelp()
return
}
if (!fs.existsSync(options.workbookPath)) {
throw new Error(`Excel-Datei nicht gefunden: ${options.workbookPath}`)
}
const rows = parseWorkbook(options.workbookPath)
if (!rows.length) {
throw new Error("Keine importierbaren Mitarbeiter gefunden.")
}
const mappedBranchIds = [
...new Set(
Object.values(options.branchMap)
.concat(options.defaultBranchId != null ? [options.defaultBranchId] : [])
),
]
const { db, pool } = await import("../db")
try {
const { tenant } = await validateTenantAndBranches(db, options.tenantId, mappedBranchIds)
const teamByBranchAndName = await loadTenantTeams(db, options.tenantId)
const existingProfiles = await db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, options.tenantId))
const existingByName = new Map<string, any>()
for (const profile of existingProfiles) {
const key = normalizeKey(`${profile.first_name} ${profile.last_name}`)
if (existingByName.has(key)) {
throw new Error(`Mehrdeutiger bestehender Mitarbeiter: ${profile.first_name} ${profile.last_name}`)
}
existingByName.set(key, profile)
}
const duplicateImportNames = new Set<string>()
const seenImportNames = new Set<string>()
for (const row of rows) {
const key = normalizeKey(row.mitarbeiter)
if (seenImportNames.has(key)) duplicateImportNames.add(row.mitarbeiter)
seenImportNames.add(key)
}
if (duplicateImportNames.size) {
throw new Error(`Die Excel-Datei enthält doppelte Mitarbeiternamen: ${[...duplicateImportNames].join(", ")}`)
}
const missingBranchMappings = new Set<string>()
const missingTeams = new Set<string>()
const preparedRows = rows.map((row) => {
const branchKey = normalizeKey(row.betrieb)
const branchId = options.branchMap[branchKey] ?? options.defaultBranchId ?? null
if (!branchId) {
missingBranchMappings.add(row.betrieb || `(Zeile ${row.rowNumber})`)
}
const teamName = buildTeamName(row.bereich, row.anstellung)
const team = branchId && teamName
? (teamByBranchAndName.get(`${branchId}::${normalizeKey(teamName)}`) as any) || null
: null
if (branchId && teamName && !team) {
missingTeams.add(`${row.betrieb} | ${teamName}`)
}
return {
...row,
branchId,
teamId: team?.id ?? null,
teamName,
weeklyHours: toWeeklyHours(row.stundenMonat),
}
})
if (missingBranchMappings.size) {
throw new Error(
`Für folgende Betriebe fehlt eine Branch-ID: ${[...missingBranchMappings].join(", ")}`
)
}
if (missingTeams.size) {
throw new Error(
`Für folgende Niederlassung-/Bereich-Kombinationen fehlen Teams: ${[...missingTeams].join(", ")}`
)
}
let createdProfiles = 0
let updatedProfiles = 0
const actionLogs: string[] = []
for (const row of preparedRows) {
const nameParts = splitFullName(row.mitarbeiter)
const nameKey = normalizeKey(row.mitarbeiter)
const existing = existingByName.get(nameKey)
const tempConfig = {
...((existing?.temp_config && typeof existing.temp_config === "object") ? existing.temp_config : {}),
mitarbeiterImport: {
betrieb: row.betrieb,
bereich: row.bereich,
stundenMonat: row.stundenMonat,
urlaub: row.urlaub,
quelle: path.basename(options.workbookPath),
importiertAm: new Date().toISOString(),
},
}
const payload = {
tenant_id: options.tenantId,
branch_id: row.branchId,
first_name: nameParts.firstName,
last_name: nameParts.lastName,
contract_type: row.anstellung || null,
position: row.position || null,
qualification: row.bereich || null,
weekly_working_hours: row.weeklyHours ?? existing?.weekly_working_hours ?? 0,
annual_paid_leave_days: row.urlaub != null ? Math.round(row.urlaub) : existing?.annual_paid_leave_days ?? null,
temp_config: tempConfig,
active: existing?.active ?? true,
}
const action: ImportAction = existing ? "update" : "create"
const fieldChanges = existing ? collectFieldChanges(existing, payload) : []
const actionPrefix = action === "create" ? "ERSTELLEN" : "AKTUALISIEREN"
const branchLabel = `${row.betrieb} -> ${row.branchId}`
const teamLabel = row.teamName ? `${row.teamName} -> ${row.teamId}` : "-"
if (action === "create") {
actionLogs.push(
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Vertrag ${row.anstellung || "-"} | Position ${row.position || "-"}`
)
} else {
actionLogs.push(
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Änderungen: ${fieldChanges.length ? fieldChanges.join("; ") : "keine"}`
)
}
if (!existing) {
if (!options.dryRun) {
const [created] = await db
.insert(authProfiles)
.values(payload)
.returning()
if (!created) {
throw new Error(`Profil für "${row.mitarbeiter}" konnte nicht erstellt werden.`)
}
await db.insert(authProfileBranches).values({
profile_id: created.id,
branch_id: row.branchId,
})
if (row.teamId) {
await db.insert(authProfileTeams).values({
profile_id: created.id,
team_id: row.teamId,
})
}
existingByName.set(nameKey, created)
}
createdProfiles += 1
continue
}
if (!options.dryRun) {
await db
.update(authProfiles)
.set(payload)
.where(
and(
eq(authProfiles.id, existing.id),
eq(authProfiles.tenant_id, options.tenantId)
)
)
await db
.delete(authProfileBranches)
.where(eq(authProfileBranches.profile_id, existing.id))
await db.insert(authProfileBranches).values({
profile_id: existing.id,
branch_id: row.branchId,
})
await db
.delete(authProfileTeams)
.where(eq(authProfileTeams.profile_id, existing.id))
if (row.teamId) {
await db.insert(authProfileTeams).values({
profile_id: existing.id,
team_id: row.teamId,
})
}
}
updatedProfiles += 1
}
console.log("")
console.log(`[IMPORT MITARBEITER] Tenant: ${tenant.id} (${tenant.name})`)
console.log(`[IMPORT MITARBEITER] Datei: ${options.workbookPath}`)
console.log(`[IMPORT MITARBEITER] Dry-Run: ${options.dryRun ? "JA" : "NEIN"}`)
console.log(`[IMPORT MITARBEITER] Zeilen gelesen: ${rows.length}`)
console.log(`[IMPORT MITARBEITER] Profile erstellt: ${createdProfiles}`)
console.log(`[IMPORT MITARBEITER] Profile aktualisiert: ${updatedProfiles}`)
if (actionLogs.length) {
console.log("[IMPORT MITARBEITER] Details:")
for (const logLine of actionLogs) {
console.log(` ${logLine}`)
}
}
console.log("")
} finally {
await pool.end()
}
}
main().catch((error) => {
console.error("[IMPORT MITARBEITER] Fehler:", error)
process.exitCode = 1
})

View File

@@ -0,0 +1,47 @@
import "dotenv/config"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator"
import { Pool } from "pg"
import { loadSecrets, secrets } from "../src/utils/secrets"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
async function run() {
let connectionString = process.env.DATABASE_URL
if (!connectionString) {
await loadSecrets()
connectionString = secrets.DATABASE_URL
}
if (!connectionString) {
throw new Error("DATABASE_URL not configured")
}
const pool = new Pool({
connectionString,
max: 1,
})
try {
const db = drizzle(pool)
await migrate(db, {
migrationsFolder: path.resolve(__dirname, "../db/migrations"),
})
console.log("✅ Drizzle-Migrationen erfolgreich angewendet")
} finally {
await pool.end()
}
}
run().catch((err) => {
console.error("❌ Migration fehlgeschlagen")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,7 @@
{
"1848 Pütt": 1,
"Strandcafé": 3,
"Oceans11": 4,
"Oceans 11": 4,
"Winnys": 5
}

View File

@@ -29,6 +29,9 @@ import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user";
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
import wikiRoutes from "./routes/wiki";
import portalContractRoutes from "./routes/portal/contracts";
import mcpRoutes from "./routes/mcp";
import communicationRoutes from "./routes/communication";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -53,6 +56,7 @@ import {sendMail} from "./utils/mailer";
import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3";
import { runBootstrap } from "./modules/bootstrap.service";
//Services
@@ -77,6 +81,7 @@ async function main() {
await app.register(dayjsPlugin);
await app.register(dbPlugin);
await app.register(servicesPlugin);
await runBootstrap(app);
app.addHook('preHandler', (req, reply, done) => {
console.log(req.method)
@@ -146,6 +151,9 @@ async function main() {
await subApp.register(publiclinksAuthenticatedRoutes);
await subApp.register(resourceRoutes);
await subApp.register(wikiRoutes);
await subApp.register(portalContractRoutes);
await subApp.register(mcpRoutes);
await subApp.register(communicationRoutes);
},{prefix: "/api"})

88
backend/src/mcp/authz.ts Normal file
View File

@@ -0,0 +1,88 @@
import { FastifyInstance, FastifyRequest } from "fastify"
import { and, eq, or, isNull, inArray } from "drizzle-orm"
import {
authRoles,
authRolePermissions,
authUserRoles,
} from "../../db/schema"
import { McpContext, McpTool } from "./types"
export async function loadTenantPermissions(
server: FastifyInstance,
userId: string,
tenantId: number
) {
const roleRows = await server.db
.select({
roleId: authUserRoles.role_id,
})
.from(authUserRoles)
.innerJoin(
authRoles,
and(
eq(authRoles.id, authUserRoles.role_id),
or(isNull(authRoles.tenant_id), eq(authRoles.tenant_id, tenantId))
)
)
.where(
and(
eq(authUserRoles.user_id, userId),
eq(authUserRoles.tenant_id, tenantId)
)
)
const roleIds = Array.from(new Set(roleRows.map((row) => row.roleId)))
if (roleIds.length === 0) return []
const permissionRows = await server.db
.select({
permission: authRolePermissions.permission,
})
.from(authRolePermissions)
.where(inArray(authRolePermissions.role_id, roleIds))
return Array.from(new Set(permissionRows.map((row) => row.permission)))
}
export async function createMcpContext(
server: FastifyInstance,
request: FastifyRequest
): Promise<McpContext> {
const user = request.user
if (!user?.user_id) {
throw Object.assign(new Error("Authentication required"), { statusCode: 401 })
}
if (!user.tenant_id) {
throw Object.assign(new Error("MCP benötigt einen aktiven Mandanten"), { statusCode: 403 })
}
const permissions = await loadTenantPermissions(server, user.user_id, user.tenant_id)
return {
server,
request,
tenantId: user.tenant_id,
userId: user.user_id,
isAdmin: Boolean(user.is_admin),
permissions,
}
}
export function assertToolPermission(context: McpContext, tool: McpTool) {
if (context.isAdmin) return
const allowed = tool.requiredPermissions.every((permission) =>
context.permissions.includes(permission)
)
if (!allowed) {
throw Object.assign(
new Error(`Fehlende Berechtigung für ${tool.name}: ${tool.requiredPermissions.join(", ")}`),
{ statusCode: 403 }
)
}
}

View File

@@ -0,0 +1,11 @@
import { accountingTools } from "./tools/accounting"
import { masterdataTools } from "./tools/masterdata"
import { organisationTools } from "./tools/organisation"
export const mcpTools = [
...accountingTools,
...masterdataTools,
...organisationTools,
]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

36
backend/src/mcp/result.ts Normal file
View File

@@ -0,0 +1,36 @@
import { McpToolResult } from "./types"
export function asToolResult(payload: unknown): McpToolResult {
const structuredContent =
payload && typeof payload === "object" && !Array.isArray(payload)
? payload as Record<string, unknown>
: { result: payload }
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
structuredContent,
}
}
export function asToolError(error: unknown): McpToolResult {
const message = error instanceof Error ? error.message : "Unbekannter Fehler"
return {
content: [
{
type: "text",
text: message,
},
],
isError: true,
structuredContent: {
error: message,
},
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,571 @@
import { and, desc, eq, ilike, or } from "drizzle-orm"
import {
branches,
contacts,
costcentres,
customers,
inventoryitems,
products,
services,
teams,
units,
vehicles,
vendors,
} from "../../../db/schema"
import { McpTool } from "../types"
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
const raw = Number(args.limit ?? fallback)
if (!Number.isFinite(raw)) return fallback
return Math.min(Math.max(Math.trunc(raw), 1), 100)
}
const stringArg = (args: Record<string, unknown>, key: string) => {
const value = args[key]
return typeof value === "string" && value.trim() ? value.trim() : null
}
const numberArg = (args: Record<string, unknown>, key: string) => {
const value = Number(args[key])
return Number.isFinite(value) ? value : null
}
const uuidArg = (args: Record<string, unknown>, key: string) => {
const value = stringArg(args, key)
return value && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
? value
: null
}
export const masterdataTools: McpTool[] = [
{
name: "masterdata.customers.get",
title: "Kunde laden",
description: "Lädt einen Kunden des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["masterdata.customers.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(customers)
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Kunde nicht gefunden")
return { customer: rows[0] }
},
},
{
name: "masterdata.vendors.search",
title: "Lieferanten suchen",
description: "Sucht Lieferanten des aktiven Mandanten.",
requiredPermissions: ["masterdata.vendors.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(vendors.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(vendors.name, `%${query}%`),
ilike(vendors.vendorNumber, `%${query}%`),
ilike(vendors.notes, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
const rows = await context.server.db
.select()
.from(vendors)
.where(and(...conditions))
.orderBy(vendors.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.vendors.get",
title: "Lieferant laden",
description: "Lädt einen Lieferanten des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["masterdata.vendors.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(vendors)
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
return { vendor: rows[0] }
},
},
{
name: "masterdata.contacts.search",
title: "Kontakte suchen",
description: "Sucht Kontakte des aktiven Mandanten, optional zu Kunde oder Lieferant.",
requiredPermissions: ["masterdata.contacts.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
customer: { type: "number" },
vendor: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(contacts.tenant, context.tenantId)]
const query = stringArg(args, "query")
const customer = numberArg(args, "customer")
const vendor = numberArg(args, "vendor")
if (query) {
conditions.push(or(
ilike(contacts.fullName, `%${query}%`),
ilike(contacts.firstName, `%${query}%`),
ilike(contacts.lastName, `%${query}%`),
ilike(contacts.email, `%${query}%`),
ilike(contacts.phoneMobile, `%${query}%`),
ilike(contacts.phoneHome, `%${query}%`),
ilike(contacts.role, `%${query}%`),
ilike(contacts.notes, `%${query}%`)
))
}
if (customer) conditions.push(eq(contacts.customer, customer))
if (vendor) conditions.push(eq(contacts.vendor, vendor))
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
const rows = await context.server.db
.select()
.from(contacts)
.where(and(...conditions))
.orderBy(contacts.fullName)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.products.search",
title: "Artikel suchen",
description: "Sucht Artikel und Materialstammdaten des aktiven Mandanten.",
requiredPermissions: ["masterdata.products.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(products.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(products.name, `%${query}%`),
ilike(products.article_number, `%${query}%`),
ilike(products.manufacturer, `%${query}%`),
ilike(products.manufacturer_number, `%${query}%`),
ilike(products.ean, `%${query}%`),
ilike(products.barcode, `%${query}%`),
ilike(products.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
const rows = await context.server.db
.select()
.from(products)
.where(and(...conditions))
.orderBy(products.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.products.get",
title: "Artikel laden",
description: "Lädt einen Artikel des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["masterdata.products.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(products)
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Artikel nicht gefunden")
return { product: rows[0] }
},
},
{
name: "masterdata.services.search",
title: "Leistungen suchen",
description: "Sucht Leistungsstammdaten des aktiven Mandanten.",
requiredPermissions: ["masterdata.services.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(services.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(services.name, `%${query}%`),
ilike(services.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
const rows = await context.server.db
.select()
.from(services)
.where(and(...conditions))
.orderBy(services.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.services.get",
title: "Leistung laden",
description: "Lädt eine Leistung des aktiven Mandanten anhand ihrer ID.",
requiredPermissions: ["masterdata.services.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(services)
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Leistung nicht gefunden")
return { service: rows[0] }
},
},
{
name: "masterdata.cost_centres.list",
title: "Kostenstellen auflisten",
description: "Listet Kostenstellen des aktiven Mandanten.",
requiredPermissions: ["masterdata.cost_centres.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
branch: { type: "number" },
project: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(costcentres.tenant, context.tenantId)]
const query = stringArg(args, "query")
const branch = numberArg(args, "branch")
const project = numberArg(args, "project")
if (query) {
conditions.push(or(
ilike(costcentres.number, `%${query}%`),
ilike(costcentres.name, `%${query}%`),
ilike(costcentres.description, `%${query}%`)
))
}
if (branch) conditions.push(eq(costcentres.branch, branch))
if (project) conditions.push(eq(costcentres.project, project))
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
const rows = await context.server.db
.select()
.from(costcentres)
.where(and(...conditions))
.orderBy(costcentres.number)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.cost_centres.get",
title: "Kostenstelle laden",
description: "Lädt eine Kostenstelle des aktiven Mandanten anhand ihrer UUID.",
requiredPermissions: ["masterdata.cost_centres.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "string" } },
},
async handler(context, args) {
const id = uuidArg(args, "id")
if (!id) throw new Error("gültige id ist erforderlich")
const rows = await context.server.db
.select()
.from(costcentres)
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
return { costCentre: rows[0] }
},
},
{
name: "masterdata.branches.list",
title: "Niederlassungen auflisten",
description: "Listet Niederlassungen des aktiven Mandanten.",
requiredPermissions: ["masterdata.branches.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(branches.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(branches.number, `%${query}%`),
ilike(branches.name, `%${query}%`),
ilike(branches.description, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
const rows = await context.server.db
.select()
.from(branches)
.where(and(...conditions))
.orderBy(branches.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.teams.list",
title: "Teams auflisten",
description: "Listet Teams des aktiven Mandanten.",
requiredPermissions: ["masterdata.teams.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
branch: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(teams.tenant, context.tenantId)]
const query = stringArg(args, "query")
const branch = numberArg(args, "branch")
if (query) {
conditions.push(or(
ilike(teams.name, `%${query}%`),
ilike(teams.description, `%${query}%`)
))
}
if (branch) conditions.push(eq(teams.branch, branch))
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
const rows = await context.server.db
.select()
.from(teams)
.where(and(...conditions))
.orderBy(teams.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.vehicles.list",
title: "Fahrzeuge auflisten",
description: "Listet Fahrzeuge des aktiven Mandanten.",
requiredPermissions: ["masterdata.vehicles.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(vehicles.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(vehicles.name, `%${query}%`),
ilike(vehicles.license_plate, `%${query}%`),
ilike(vehicles.vin, `%${query}%`),
ilike(vehicles.color, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
const rows = await context.server.db
.select()
.from(vehicles)
.where(and(...conditions))
.orderBy(vehicles.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.inventory_items.search",
title: "Inventar suchen",
description: "Sucht Inventar- und Geräte-Stammdaten des aktiven Mandanten.",
requiredPermissions: ["masterdata.inventory_items.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
vendor: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(inventoryitems.tenant, context.tenantId)]
const query = stringArg(args, "query")
const vendor = numberArg(args, "vendor")
if (query) {
conditions.push(or(
ilike(inventoryitems.name, `%${query}%`),
ilike(inventoryitems.articleNumber, `%${query}%`),
ilike(inventoryitems.serialNumber, `%${query}%`),
ilike(inventoryitems.manufacturer, `%${query}%`),
ilike(inventoryitems.manufacturerNumber, `%${query}%`),
ilike(inventoryitems.description, `%${query}%`)
))
}
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
const rows = await context.server.db
.select()
.from(inventoryitems)
.where(and(...conditions))
.orderBy(inventoryitems.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "masterdata.inventory_items.get",
title: "Inventar laden",
description: "Lädt einen Inventar- oder Geräte-Stammdatensatz anhand seiner ID.",
requiredPermissions: ["masterdata.inventory_items.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: { id: { type: "number" } },
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(inventoryitems)
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Inventar nicht gefunden")
return { inventoryItem: rows[0] }
},
},
{
name: "masterdata.units.list",
title: "Einheiten auflisten",
description: "Listet globale Mengeneinheiten.",
requiredPermissions: ["masterdata.units.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const query = stringArg(args, "query")
const rows = await context.server.db
.select()
.from(units)
.where(query
? or(
ilike(units.name, `%${query}%`),
ilike(units.single, `%${query}%`),
ilike(units.multiple, `%${query}%`),
ilike(units.short, `%${query}%`)
)
: undefined)
.orderBy(units.name)
.limit(limitFromArgs(args))
return { rows }
},
},
]

View File

@@ -0,0 +1,407 @@
import { and, desc, eq, ilike, or } from "drizzle-orm"
import { customers, events, plants, projects, tasks } from "../../../db/schema"
import { McpTool } from "../types"
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
const raw = Number(args.limit ?? fallback)
if (!Number.isFinite(raw)) return fallback
return Math.min(Math.max(Math.trunc(raw), 1), 100)
}
const stringArg = (args: Record<string, unknown>, key: string) => {
const value = args[key]
return typeof value === "string" && value.trim() ? value.trim() : null
}
const numberArg = (args: Record<string, unknown>, key: string) => {
const value = Number(args[key])
return Number.isFinite(value) ? value : null
}
export const organisationTools: McpTool[] = [
{
name: "organisation.customers.search",
title: "Kunden suchen",
description: "Sucht aktive Kunden des aktiven Mandanten.",
requiredPermissions: ["organisation.customers.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(customers.tenant, context.tenantId)]
const query = stringArg(args, "query")
if (query) {
conditions.push(or(
ilike(customers.name, `%${query}%`),
ilike(customers.customerNumber, `%${query}%`),
ilike(customers.firstname, `%${query}%`),
ilike(customers.lastname, `%${query}%`),
ilike(customers.notes, `%${query}%`)
))
}
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
const rows = await context.server.db
.select({
id: customers.id,
customerNumber: customers.customerNumber,
name: customers.name,
firstname: customers.firstname,
lastname: customers.lastname,
type: customers.type,
isCompany: customers.isCompany,
active: customers.active,
archived: customers.archived,
})
.from(customers)
.where(and(...conditions))
.orderBy(customers.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.projects.list",
title: "Projekte auflisten",
description: "Listet Projekte des aktiven Mandanten mit optionalen Filtern.",
requiredPermissions: ["organisation.projects.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
customer: { type: "number" },
activePhase: { type: "string" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(projects.tenant, context.tenantId)]
const query = stringArg(args, "query")
const customer = numberArg(args, "customer")
const activePhase = stringArg(args, "activePhase")
if (query) {
conditions.push(or(
ilike(projects.name, `%${query}%`),
ilike(projects.projectNumber, `%${query}%`),
ilike(projects.customerRef, `%${query}%`),
ilike(projects.notes, `%${query}%`)
))
}
if (customer) conditions.push(eq(projects.customer, customer))
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
const rows = await context.server.db
.select()
.from(projects)
.where(and(...conditions))
.orderBy(desc(projects.createdAt))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.projects.get",
title: "Projekt laden",
description: "Lädt ein Projekt des aktiven Mandanten anhand seiner ID.",
requiredPermissions: ["organisation.projects.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(projects)
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Projekt nicht gefunden")
return { project: rows[0] }
},
},
{
name: "organisation.plants.list",
title: "Anlagen auflisten",
description: "Listet Anlagen des aktiven Mandanten mit optionalem Kundenfilter.",
requiredPermissions: ["organisation.plants.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name." },
customer: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(plants.tenant, context.tenantId)]
const query = stringArg(args, "query")
const customer = numberArg(args, "customer")
if (query) conditions.push(ilike(plants.name, `%${query}%`))
if (customer) conditions.push(eq(plants.customer, customer))
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
const rows = await context.server.db
.select()
.from(plants)
.where(and(...conditions))
.orderBy(plants.name)
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.events.list",
title: "Termine auflisten",
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
requiredPermissions: ["organisation.events.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
project: { type: "number" },
customer: { type: "number" },
eventtype: { type: "string" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(events.tenant, context.tenantId)]
const query = stringArg(args, "query")
const project = numberArg(args, "project")
const customer = numberArg(args, "customer")
const eventtype = stringArg(args, "eventtype")
if (query) {
conditions.push(or(
ilike(events.name, `%${query}%`),
ilike(events.notes, `%${query}%`),
ilike(events.link, `%${query}%`)
))
}
if (project) conditions.push(eq(events.project, project))
if (customer) conditions.push(eq(events.customer, customer))
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
const rows = await context.server.db
.select()
.from(events)
.where(and(...conditions))
.orderBy(desc(events.startDate))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.tasks.list",
title: "Aufgaben auflisten",
description: "Listet Aufgaben des aktiven Mandanten mit optionalen Filtern.",
requiredPermissions: ["organisation.tasks.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
project: { type: "number" },
customer: { type: "number" },
includeArchived: { type: "boolean", default: false },
limit: { type: "number", minimum: 1, maximum: 100 },
},
},
async handler(context, args) {
const conditions = [eq(tasks.tenant, context.tenantId)]
const query = stringArg(args, "query")
const project = numberArg(args, "project")
const customer = numberArg(args, "customer")
if (query) {
conditions.push(or(
ilike(tasks.name, `%${query}%`),
ilike(tasks.description, `%${query}%`),
ilike(tasks.categorie, `%${query}%`)
))
}
if (project) conditions.push(eq(tasks.project, project))
if (customer) conditions.push(eq(tasks.customer, customer))
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
const rows = await context.server.db
.select()
.from(tasks)
.where(and(...conditions))
.orderBy(desc(tasks.createdAt))
.limit(limitFromArgs(args))
return { rows }
},
},
{
name: "organisation.tasks.get",
title: "Aufgabe laden",
description: "Lädt eine Aufgabe des aktiven Mandanten anhand ihrer ID.",
requiredPermissions: ["organisation.tasks.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const rows = await context.server.db
.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
.limit(1)
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
return { task: rows[0] }
},
},
{
name: "organisation.tasks.create",
title: "Aufgabe erstellen",
description: "Erstellt eine neue Aufgabe im aktiven Mandanten.",
requiredPermissions: ["organisation.tasks.write"],
inputSchema: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
description: { type: "string" },
categorie: { type: "string" },
project: { type: "number" },
plant: { type: "number" },
customer: { type: "number" },
userId: { type: "string" },
profiles: { type: "array", items: { type: "string" } },
},
},
async handler(context, args) {
const name = stringArg(args, "name")
if (!name) throw new Error("name ist erforderlich")
const [created] = await context.server.db
.insert(tasks)
.values({
name,
description: stringArg(args, "description"),
categorie: stringArg(args, "categorie"),
tenant: context.tenantId,
userId: stringArg(args, "userId") || context.userId,
project: numberArg(args, "project"),
plant: numberArg(args, "plant"),
customer: numberArg(args, "customer"),
profiles: Array.isArray(args.profiles) ? args.profiles : [],
updatedAt: new Date(),
updatedBy: context.userId,
})
.returning()
return { task: created }
},
},
{
name: "organisation.tasks.update",
title: "Aufgabe aktualisieren",
description: "Aktualisiert Felder einer Aufgabe im aktiven Mandanten.",
requiredPermissions: ["organisation.tasks.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
name: { type: "string" },
description: { type: "string" },
categorie: { type: "string" },
project: { type: "number" },
plant: { type: "number" },
customer: { type: "number" },
userId: { type: "string" },
profiles: { type: "array", items: { type: "string" } },
archived: { type: "boolean" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const update: Record<string, unknown> = {
updatedAt: new Date(),
updatedBy: context.userId,
}
for (const key of ["name", "description", "categorie", "userId"] as const) {
if (args[key] !== undefined) update[key] = stringArg(args, key)
}
for (const key of ["project", "plant", "customer"] as const) {
if (args[key] !== undefined) update[key] = numberArg(args, key)
}
if (Array.isArray(args.profiles)) update.profiles = args.profiles
if (typeof args.archived === "boolean") update.archived = args.archived
const [updated] = await context.server.db
.update(tasks)
.set(update)
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
.returning()
if (!updated) throw new Error("Aufgabe nicht gefunden")
return { task: updated }
},
},
{
name: "organisation.tasks.archive",
title: "Aufgabe archivieren",
description: "Archiviert eine Aufgabe im aktiven Mandanten.",
requiredPermissions: ["organisation.tasks.write"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const [updated] = await context.server.db
.update(tasks)
.set({
archived: true,
updatedAt: new Date(),
updatedBy: context.userId,
})
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
.returning()
if (!updated) throw new Error("Aufgabe nicht gefunden")
return { task: updated }
},
},
]

36
backend/src/mcp/types.ts Normal file
View File

@@ -0,0 +1,36 @@
import { FastifyInstance, FastifyRequest } from "fastify"
export type McpContext = {
server: FastifyInstance
request: FastifyRequest
tenantId: number
userId: string
isAdmin: boolean
permissions: string[]
}
export type McpToolResult = {
content: Array<{
type: "text"
text: string
}>
structuredContent?: Record<string, unknown>
isError?: boolean
}
export type McpTool = {
name: string
title: string
description: string
requiredPermissions: string[]
inputSchema: Record<string, unknown>
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
}
export type JsonRpcRequest = {
jsonrpc?: string
id?: string | number | null
method?: string
params?: any
}

View File

@@ -0,0 +1,490 @@
import { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import bcrypt from "bcrypt"
import {
accounts,
authProfiles,
authRoles,
authRolePermissions,
authTenantUsers,
authUserRoles,
authUsers,
branches,
filetags,
folders,
productcategories,
servicecategories,
taxTypes,
teams,
tenants,
texttemplates,
units,
} from "../../db/schema"
const adminPermissions = [
"mcp.tokens.write",
"staff.time.read_all",
"masterdata.customers.read",
"masterdata.vendors.read",
"masterdata.contacts.read",
"masterdata.products.read",
"masterdata.services.read",
"masterdata.cost_centres.read",
"masterdata.branches.read",
"masterdata.teams.read",
"masterdata.vehicles.read",
"masterdata.inventory_items.read",
"masterdata.units.read",
"accounting.outgoing_documents.read",
"accounting.outgoing_documents.write",
"accounting.accounts.read",
"accounting.incoming_invoices.read",
"accounting.incoming_invoices.write",
"accounting.bank.read",
"accounting.statement_allocations.read",
"organisation.customers.read",
"organisation.projects.read",
"organisation.plants.read",
"organisation.events.read",
"organisation.tasks.read",
"organisation.tasks.write",
]
const defaultUnits = [
{ name: "Stück", single: "Stück", multiple: "Stück", short: "Stk.", step: "1" },
{ name: "Stunde", single: "Stunde", multiple: "Stunden", short: "Std.", step: "0.25" },
{ name: "Pauschale", single: "Pauschale", multiple: "Pauschalen", short: "Psch.", step: "1" },
{ name: "Meter", single: "Meter", multiple: "Meter", short: "m", step: "0.1" },
]
const defaultTaxTypes = [
{ label: "Umsatzsteuer 19%", percentage: 19 },
{ label: "Umsatzsteuer 7%", percentage: 7 },
{ label: "Steuerfrei", percentage: 0 },
]
const defaultAccounts = [
{ number: "8400", label: "Erlöse 19% USt", accountChart: "skr03" },
{ number: "8300", label: "Erlöse 7% USt", accountChart: "skr03" },
{ number: "1200", label: "Bank", accountChart: "skr03" },
{ number: "1000", label: "Kasse", accountChart: "skr03" },
{ number: "1400", label: "Forderungen aus Lieferungen und Leistungen", accountChart: "skr03" },
{ number: "1600", label: "Verbindlichkeiten aus Lieferungen und Leistungen", accountChart: "skr03" },
]
async function ensureGlobalDefaults(server: FastifyInstance, userId: string) {
for (const unit of defaultUnits) {
const existing = await server.db.select({ id: units.id }).from(units).where(eq(units.name, unit.name)).limit(1)
if (!existing.length) await server.db.insert(units).values(unit)
}
for (const taxType of defaultTaxTypes) {
const existing = await server.db
.select({ id: taxTypes.id })
.from(taxTypes)
.where(eq(taxTypes.percentage, taxType.percentage))
.limit(1)
if (!existing.length) {
await server.db.insert(taxTypes).values({
...taxType,
updatedAt: new Date(),
updatedBy: userId,
})
}
}
for (const account of defaultAccounts) {
const existing = await server.db
.select({ id: accounts.id })
.from(accounts)
.where(and(eq(accounts.accountChart, account.accountChart), eq(accounts.number, account.number)))
.limit(1)
if (!existing.length) {
await server.db.insert(accounts).values({
...account,
description: "FEDEO Standardkonto",
})
}
}
}
async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: number, userId: string) {
const currentYear = new Date().getFullYear()
const timestamp = new Date()
const tagDefaults = [
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices", isSystemUsed: true },
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes", isSystemUsed: true },
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders", isSystemUsed: true },
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes", isSystemUsed: true },
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices", isSystemUsed: true },
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders", isSystemUsed: true },
]
for (const tag of tagDefaults) {
const existing = await server.db
.select({ id: filetags.id })
.from(filetags)
.where(and(eq(filetags.tenant, tenantId), eq(filetags.name, tag.name)))
.limit(1)
if (!existing.length) {
await server.db.insert(filetags).values({
tenant: tenantId,
...tag,
})
}
}
const allTags = await server.db.select().from(filetags).where(eq(filetags.tenant, tenantId))
const tagByCreatedType = new Map(allTags.map((tag) => [tag.createdDocumentType, tag.id]))
const tagByIncomingType = new Map(allTags.map((tag) => [tag.incomingDocumentType, tag.id]))
const rootFolders = [
{ name: "Ausgangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-document-text" },
{ name: "Angebote", function: "yearSubCategory" as const, icon: "i-heroicons-document-duplicate" },
{ name: "Auftragsbestätigungen", function: "yearSubCategory" as const, icon: "i-heroicons-clipboard-document-check" },
{ name: "Lieferscheine", function: "yearSubCategory" as const, icon: "i-heroicons-truck" },
{ name: "Eingangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-inbox-arrow-down" },
{ name: "Belege Bankeinzahlung", function: "yearSubCategory" as const, icon: "i-heroicons-banknotes" },
]
for (const folder of rootFolders) {
const existing = await server.db
.select({ id: folders.id })
.from(folders)
.where(and(eq(folders.tenant, tenantId), eq(folders.name, folder.name)))
.limit(1)
if (!existing.length) {
await server.db.insert(folders).values({
tenant: tenantId,
name: folder.name,
function: folder.function,
icon: folder.icon,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: userId,
})
}
}
const allFolders = await server.db.select().from(folders).where(eq(folders.tenant, tenantId))
const rootFolderByName = new Map(allFolders.filter((folder) => !folder.parent).map((folder) => [folder.name, folder.id]))
const yearFolders = [
{
parentName: "Ausgangsrechnungen",
function: "invoices" as const,
icon: "i-heroicons-document-text",
standardFiletype: tagByCreatedType.get("invoices"),
},
{
parentName: "Angebote",
function: "quotes" as const,
icon: "i-heroicons-document-duplicate",
standardFiletype: tagByCreatedType.get("quotes"),
},
{
parentName: "Auftragsbestätigungen",
function: "confirmationOrders" as const,
icon: "i-heroicons-clipboard-document-check",
standardFiletype: tagByCreatedType.get("confirmationOrders"),
},
{
parentName: "Lieferscheine",
function: "deliveryNotes" as const,
icon: "i-heroicons-truck",
standardFiletype: tagByCreatedType.get("deliveryNotes"),
},
{
parentName: "Eingangsrechnungen",
function: "incomingInvoices" as const,
icon: "i-heroicons-inbox-arrow-down",
standardFiletype: tagByIncomingType.get("invoices"),
},
{
parentName: "Belege Bankeinzahlung",
function: "deposit" as const,
icon: "i-heroicons-banknotes",
},
]
for (const folder of yearFolders) {
const parent = rootFolderByName.get(folder.parentName)
if (!parent) continue
const existing = await server.db
.select({ id: folders.id })
.from(folders)
.where(and(eq(folders.tenant, tenantId), eq(folders.parent, parent), eq(folders.year, currentYear)))
.limit(1)
if (!existing.length) {
await server.db.insert(folders).values({
tenant: tenantId,
name: String(currentYear),
parent,
function: folder.function,
year: currentYear,
icon: folder.icon,
standardFiletype: folder.standardFiletype,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: userId,
})
}
}
}
export async function ensureTenantBaseData(server: FastifyInstance, tenantId: number, adminUserId: string) {
await ensureGlobalDefaults(server, adminUserId)
await ensureTenantFileDefaults(server, tenantId, adminUserId)
const [adminRole] = await server.db
.select({ id: authRoles.id })
.from(authRoles)
.where(and(eq(authRoles.tenant_id, tenantId), eq(authRoles.name, "Administrator")))
.limit(1)
let adminRoleId = adminRole?.id
if (!adminRoleId) {
const [createdRole] = await server.db
.insert(authRoles)
.values({
name: "Administrator",
description: "Vollzugriff für die Administration dieses Mandanten",
tenant_id: tenantId,
created_by: adminUserId,
})
.returning({ id: authRoles.id })
adminRoleId = createdRole.id
}
for (const permission of adminPermissions) {
const existing = await server.db
.select()
.from(authRolePermissions)
.where(and(eq(authRolePermissions.role_id, adminRoleId), eq(authRolePermissions.permission, permission)))
.limit(1)
if (!existing.length) {
await server.db.insert(authRolePermissions).values({
role_id: adminRoleId,
permission,
})
}
}
const membership = await server.db
.select()
.from(authTenantUsers)
.where(and(eq(authTenantUsers.tenant_id, tenantId), eq(authTenantUsers.user_id, adminUserId)))
.limit(1)
if (!membership.length) {
await server.db.insert(authTenantUsers).values({
tenant_id: tenantId,
user_id: adminUserId,
created_by: adminUserId,
})
}
const roleAssignment = await server.db
.select()
.from(authUserRoles)
.where(and(
eq(authUserRoles.tenant_id, tenantId),
eq(authUserRoles.user_id, adminUserId),
eq(authUserRoles.role_id, adminRoleId),
))
.limit(1)
if (!roleAssignment.length) {
await server.db.insert(authUserRoles).values({
tenant_id: tenantId,
user_id: adminUserId,
role_id: adminRoleId,
created_by: adminUserId,
})
}
const profile = await server.db
.select()
.from(authProfiles)
.where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.user_id, adminUserId)))
.limit(1)
if (!profile.length) {
await server.db.insert(authProfiles).values({
tenant_id: tenantId,
user_id: adminUserId,
first_name: process.env.FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME || "Admin",
last_name: process.env.FEDEO_BOOTSTRAP_ADMIN_LAST_NAME || "Benutzer",
email: process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase(),
active: true,
})
}
const branch = await server.db
.select({ id: branches.id })
.from(branches)
.where(and(eq(branches.tenant, tenantId), eq(branches.name, "Hauptstandort")))
.limit(1)
let branchId = branch[0]?.id
if (!branchId) {
const [createdBranch] = await server.db.insert(branches).values({
tenant: tenantId,
name: "Hauptstandort",
number: "001",
description: "Standardstandort",
updatedAt: new Date(),
updatedBy: adminUserId,
}).returning({ id: branches.id })
branchId = createdBranch.id
}
const team = await server.db
.select({ id: teams.id })
.from(teams)
.where(and(eq(teams.tenant, tenantId), eq(teams.name, "Standardteam")))
.limit(1)
if (!team.length) {
await server.db.insert(teams).values({
tenant: tenantId,
name: "Standardteam",
description: "Automatisch angelegtes Standardteam",
branch: branchId,
updatedAt: new Date(),
updatedBy: adminUserId,
})
}
const defaultProductCategory = await server.db
.select({ id: productcategories.id })
.from(productcategories)
.where(and(eq(productcategories.tenant, tenantId), eq(productcategories.name, "Standard")))
.limit(1)
if (!defaultProductCategory.length) {
await server.db.insert(productcategories).values({
tenant: tenantId,
name: "Standard",
description: "Standardkategorie",
updatedAt: new Date(),
updatedBy: adminUserId,
})
}
const defaultServiceCategory = await server.db
.select({ id: servicecategories.id })
.from(servicecategories)
.where(and(eq(servicecategories.tenant, tenantId), eq(servicecategories.name, "Standard")))
.limit(1)
if (!defaultServiceCategory.length) {
await server.db.insert(servicecategories).values({
tenant: tenantId,
name: "Standard",
description: "Standardkategorie",
updated_at: new Date(),
updated_by: adminUserId,
})
}
const templateDefaults = [
{ name: "Standard Einleitung", pos: "startText" as const, text: "<p>vielen Dank für Ihre Anfrage.</p>" },
{ name: "Standard Schluss", pos: "endText" as const, text: "<p>Mit freundlichen Grüßen</p>" },
]
for (const template of templateDefaults) {
const existing = await server.db
.select({ id: texttemplates.id })
.from(texttemplates)
.where(and(eq(texttemplates.tenant, tenantId), eq(texttemplates.name, template.name)))
.limit(1)
if (!existing.length) {
await server.db.insert(texttemplates).values({
tenant: tenantId,
name: template.name,
text: template.text,
pos: template.pos,
default: true,
updatedAt: new Date(),
updatedBy: adminUserId,
})
}
}
}
export async function runBootstrap(server: FastifyInstance) {
const email = process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase()
const password = process.env.FEDEO_BOOTSTRAP_ADMIN_PASSWORD
if (!email && !password) return
if (!email || !password) {
throw new Error("FEDEO_BOOTSTRAP_ADMIN_EMAIL und FEDEO_BOOTSTRAP_ADMIN_PASSWORD müssen gemeinsam gesetzt sein")
}
const [existingUser] = await server.db
.select()
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1)
let adminUser = existingUser
if (!adminUser) {
const [createdUser] = await server.db.insert(authUsers).values({
email,
passwordHash: await bcrypt.hash(password, 10),
is_admin: true,
multiTenant: true,
must_change_password: false,
updatedAt: new Date(),
}).returning()
adminUser = createdUser
console.log(`✅ Bootstrap-Admin angelegt: ${email}`)
} else if (!adminUser.is_admin) {
const [updatedUser] = await server.db.update(authUsers).set({
is_admin: true,
updatedAt: new Date(),
}).where(eq(authUsers.id, adminUser.id)).returning()
adminUser = updatedUser
console.log(`✅ Bootstrap-Adminrechte gesetzt: ${email}`)
}
const tenantName = process.env.FEDEO_BOOTSTRAP_TENANT_NAME?.trim() || "FEDEO"
const tenantShort = process.env.FEDEO_BOOTSTRAP_TENANT_SHORT?.trim() || "FEDEO"
const [existingTenant] = await server.db
.select()
.from(tenants)
.where(eq(tenants.short, tenantShort))
.limit(1)
let tenant = existingTenant
if (!tenant) {
const [createdTenant] = await server.db.insert(tenants).values({
name: tenantName,
short: tenantShort,
updatedAt: new Date(),
updatedBy: adminUser.id,
}).returning()
tenant = createdTenant
console.log(`✅ Bootstrap-Mandant angelegt: ${tenant.name}`)
}
await ensureTenantBaseData(server, tenant.id, adminUser.id)
console.log("✅ Bootstrap-Grunddaten geprüft")
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,51 @@
// services/notification.service.ts
import type { FastifyInstance } from 'fastify';
import {secrets} from "../utils/secrets";
import { eq } from "drizzle-orm";
import { notificationsEventTypes, notificationsItems } from "../../db/schema";
import type { FastifyInstance } from "fastify"
import webPush from "web-push"
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
import {
authUsers,
notificationPushSubscriptions,
notificationsEventTypes,
notificationsItems,
notificationsPreferences,
notificationsPreferencesDefaults,
} from "../../db/schema"
import { secrets } from "../utils/secrets"
export type NotificationStatus = 'queued' | 'sent' | 'failed';
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
export interface TriggerInput {
tenantId: number;
userId: string; // muss auf public.auth_users.id zeigen
eventType: string; // muss in notifications_event_types existieren
title: string; // Betreff/Title
message: string; // Klartext-Inhalt
payload?: Record<string, unknown>;
tenantId: number
userId?: string
userIds?: string[]
eventType: string
title: string
message: string
payload?: Record<string, unknown>
channels?: NotificationChannel[]
}
export interface PushSubscriptionInput {
endpoint: string
keys: {
p256dh: string
auth: string
}
deviceLabel?: string
meta?: Record<string, unknown>
}
export interface UserDirectoryInfo {
email?: string;
email?: string
}
export type UserDirectory = (server: FastifyInstance, userId: string, tenantId: number) => Promise<UserDirectoryInfo | null>;
export type UserDirectory = (
server: FastifyInstance,
userId: string,
tenantId: number
) => Promise<UserDirectoryInfo | null>
const DEFAULT_CHANNELS: NotificationChannel[] = ["inapp"]
export class NotificationService {
constructor(
@@ -27,99 +53,355 @@ export class NotificationService {
private getUser: UserDirectory
) {}
/**
* Löst eine E-Mail-Benachrichtigung aus:
* - Validiert den Event-Typ
* - Legt einen Datensatz in notifications_items an (status: queued)
* - Versendet E-Mail (FEDEO Branding)
* - Aktualisiert status/sent_at bzw. error
*/
async trigger(input: TriggerInput) {
const { tenantId, userId, eventType, title, message, payload } = input;
const tenantId = input.tenantId
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
// 1) Event-Typ prüfen (aktiv?)
const eventTypeRows = await this.server.db
if (!tenantId) throw new Error("tenantId fehlt")
if (!userIds.length) throw new Error("Keine Empfänger angegeben")
const eventType = await this.getActiveEventType(input.eventType)
const allowedChannels = this.normalizeChannels(eventType.allowedChannels)
const requestedChannels = input.channels?.length ? input.channels : allowedChannels
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
if (!channels.length) {
return { success: true, created: 0, delivered: 0, skipped: userIds.length }
}
const results = []
for (const userId of userIds) {
const enabledChannels = await this.resolveEnabledChannels({
tenantId,
userId,
eventType: input.eventType,
channels,
})
for (const channel of enabledChannels) {
const itemRows = await this.server.db
.insert(notificationsItems)
.values({
tenantId,
userId,
eventType: input.eventType,
title: input.title,
message: input.message,
payload: input.payload ?? null,
channel,
status: "queued",
})
.returning()
const item = itemRows[0]
if (!item) continue
results.push(await this.deliver(item))
}
}
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
}
async listForUser(tenantId: number, userId: string, limit = 50) {
return await this.server.db
.select()
.from(notificationsItems)
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
eq(notificationsItems.channel, "inapp")
))
.orderBy(desc(notificationsItems.createdAt))
.limit(Math.min(Math.max(limit, 1), 100))
}
async markRead(tenantId: number, userId: string, notificationId: string) {
const rows = await this.server.db
.update(notificationsItems)
.set({ readAt: new Date(), status: "read" })
.where(and(
eq(notificationsItems.id, notificationId),
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId)
))
.returning()
return rows[0] || null
}
async registerPushSubscription(
tenantId: number,
userId: string,
subscription: PushSubscriptionInput,
userAgent?: string
) {
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
throw new Error("Push-Subscription ist unvollständig")
}
const rows = await this.server.db
.insert(notificationPushSubscriptions)
.values({
tenantId,
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
deviceLabel: subscription.deviceLabel,
meta: subscription.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
})
.onConflictDoUpdate({
target: notificationPushSubscriptions.endpoint,
set: {
tenantId,
userId,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
deviceLabel: subscription.deviceLabel,
meta: subscription.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
},
})
.returning()
return rows[0]
}
async disablePushSubscription(tenantId: number, userId: string, endpoint: string) {
await this.server.db
.update(notificationPushSubscriptions)
.set({ disabledAt: new Date() })
.where(and(
eq(notificationPushSubscriptions.tenantId, tenantId),
eq(notificationPushSubscriptions.userId, userId),
eq(notificationPushSubscriptions.endpoint, endpoint)
))
return { success: true }
}
getPublicPushConfig() {
return {
configured: Boolean(secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY),
publicKey: secrets.WEB_PUSH_PUBLIC_KEY || "",
}
}
private async getActiveEventType(eventType: string) {
const rows = await this.server.db
.select()
.from(notificationsEventTypes)
.where(eq(notificationsEventTypes.eventKey, eventType))
.limit(1)
const eventTypeRow = eventTypeRows[0]
if (!eventTypeRow || eventTypeRow.isActive !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
const row = rows[0]
if (!row || row.isActive !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`)
}
// 2) Zieladresse beschaffen
const user = await this.getUser(this.server, userId, tenantId);
if (!user?.email) {
throw new Error(`Nutzer ${userId} hat keine E-Mail-Adresse`);
return row
}
private normalizeChannels(value: unknown): NotificationChannel[] {
if (!Array.isArray(value)) return DEFAULT_CHANNELS
const valid = new Set(["inapp", "email", "push", "webhook", "sms"])
const channels = value.filter((channel): channel is NotificationChannel =>
typeof channel === "string" && valid.has(channel)
)
return channels.length ? channels : DEFAULT_CHANNELS
}
private async resolveEnabledChannels(input: {
tenantId: number
userId: string
eventType: string
channels: NotificationChannel[]
}) {
const prefs = await this.server.db
.select()
.from(notificationsPreferences)
.where(and(
eq(notificationsPreferences.tenantId, input.tenantId),
eq(notificationsPreferences.userId, input.userId),
eq(notificationsPreferences.eventType, input.eventType),
inArray(notificationsPreferences.channel, input.channels)
))
const defaults = await this.server.db
.select()
.from(notificationsPreferencesDefaults)
.where(and(
eq(notificationsPreferencesDefaults.tenantId, input.tenantId),
eq(notificationsPreferencesDefaults.eventKey, input.eventType),
inArray(notificationsPreferencesDefaults.channel, input.channels)
))
return input.channels.filter((channel) => {
const userPref = prefs.find((pref) => pref.channel === channel)
if (userPref) return userPref.enabled
const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled
return true
})
}
private async deliver(item: typeof notificationsItems.$inferSelect) {
if (item.channel === "inapp") {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }
}
// 3) Notification anlegen (status: queued)
const insertedRows = await this.server.db
.insert(notificationsItems)
.values({
tenantId,
userId,
eventType,
title,
message,
payload: payload ?? null,
channel: 'email',
status: 'queued'
})
.returning({ id: notificationsItems.id })
const inserted = insertedRows[0]
if (!inserted) {
throw new Error("Fehler beim Einfügen der Notification");
if (item.channel === "push") {
return await this.deliverPush(item)
}
// 4) E-Mail versenden
if (item.channel === "email") {
return await this.deliverEmail(item)
}
await this.markFailed(item.id, `Kein Zusteller für Kanal ${item.channel}`)
return { success: false, id: item.id, channel: item.channel }
}
private async deliverPush(item: typeof notificationsItems.$inferSelect) {
if (!secrets.WEB_PUSH_PUBLIC_KEY || !secrets.WEB_PUSH_PRIVATE_KEY) {
await this.markFailed(item.id, "Web Push ist nicht konfiguriert")
return { success: false, id: item.id, channel: item.channel }
}
webPush.setVapidDetails(
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
secrets.WEB_PUSH_PUBLIC_KEY,
secrets.WEB_PUSH_PRIVATE_KEY
)
const subscriptions = await this.server.db
.select()
.from(notificationPushSubscriptions)
.where(and(
eq(notificationPushSubscriptions.tenantId, item.tenantId),
eq(notificationPushSubscriptions.userId, item.userId),
isNull(notificationPushSubscriptions.disabledAt)
))
if (!subscriptions.length) {
await this.markFailed(item.id, "Keine aktive Push-Subscription")
return { success: false, id: item.id, channel: item.channel }
}
const payload = JSON.stringify({
id: item.id,
title: item.title,
message: item.message,
payload: item.payload || {},
})
let delivered = 0
const errors: string[] = []
for (const subscription of subscriptions) {
try {
await webPush.sendNotification({
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}, payload)
delivered++
await this.server.db
.update(notificationPushSubscriptions)
.set({ lastSeenAt: new Date() })
.where(eq(notificationPushSubscriptions.id, subscription.id))
} catch (error: any) {
errors.push(error?.message || String(error))
if (error?.statusCode === 404 || error?.statusCode === 410) {
await this.server.db
.update(notificationPushSubscriptions)
.set({ disabledAt: new Date() })
.where(eq(notificationPushSubscriptions.id, subscription.id))
}
}
}
if (delivered > 0) {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel, delivered }
}
await this.markFailed(item.id, errors.join("; ") || "Push konnte nicht zugestellt werden")
return { success: false, id: item.id, channel: item.channel }
}
private async deliverEmail(item: typeof notificationsItems.$inferSelect) {
try {
await this.sendEmail(user.email, title, message);
const user = await this.getUser(this.server, item.userId, item.tenantId)
if (!user?.email) throw new Error(`Nutzer ${item.userId} hat keine E-Mail-Adresse`)
await this.server.db
.update(notificationsItems)
.set({ status: 'sent', sentAt: new Date() })
.where(eq(notificationsItems.id, inserted.id));
return { success: true, id: inserted.id };
} catch (err: any) {
await this.server.db
.update(notificationsItems)
.set({ status: 'failed', error: String(err?.message || err) })
.where(eq(notificationsItems.id, inserted.id));
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };
await this.sendEmail(user.email, item.title, item.message)
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }
} catch (error: any) {
await this.markFailed(item.id, error?.message || "E-Mail Versand fehlgeschlagen")
this.server.log.error({ err: error, notificationId: item.id }, "E-Mail Versand fehlgeschlagen")
return { success: false, id: item.id, channel: item.channel }
}
}
// ---- private helpers ------------------------------------------------------
private async markSent(id: string) {
await this.server.db
.update(notificationsItems)
.set({ status: "sent", sentAt: new Date() })
.where(eq(notificationsItems.id, id))
}
private async markFailed(id: string, error: string) {
await this.server.db
.update(notificationsItems)
.set({ status: "failed", error })
.where(eq(notificationsItems.id, id))
}
private async sendEmail(to: string, subject: string, message: string) {
const nodemailer = await import('nodemailer');
const nodemailer = await import("nodemailer")
const transporter = nodemailer.createTransport({
host: secrets.MAILER_SMTP_HOST,
port: Number(secrets.MAILER_SMTP_PORT),
secure: secrets.MAILER_SMTP_SSL === 'true',
secure: secrets.MAILER_SMTP_SSL === "true",
auth: {
user: secrets.MAILER_SMTP_USER,
pass: secrets.MAILER_SMTP_PASS
}
});
pass: secrets.MAILER_SMTP_PASS,
},
})
const html = this.renderFedeoHtml(subject, message);
const html = this.renderFedeoHtml(subject, message)
await transporter.sendMail({
from: secrets.MAILER_FROM,
to,
subject,
text: message,
html
});
html,
})
}
private renderFedeoHtml(title: string, message: string) {
@@ -133,18 +415,17 @@ export class NotificationService {
<p style="font-size:12px;color:#666">Automatisch generiert von FEDEO</p>
</div>
</body></html>
`;
`
}
// simple escaping (ausreichend für unser Template)
private escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private nl2br(s: string) {
return s.replace(/\n/g, '<br/>');
return s.replace(/\n/g, "<br/>")
}
}

View File

@@ -9,12 +9,15 @@ export type DerivedSpan = {
sourceEventIds: string[];
status: SpanStatus;
statusActorId?: string;
payload?: Record<string, any> | null;
description?: string;
};
type TimeEvent = {
id: string;
eventtype: string;
eventtime: Date;
payload?: Record<string, any> | null;
};
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
@@ -45,9 +48,17 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
let currentStart: Date | null = null;
let currentType: DerivedSpan["type"] | null = null;
let sourceEventIds: string[] = [];
let currentPayload: Record<string, any> | null = null;
const closeSpan = (end: Date) => {
if (!currentStart || !currentType) return;
if (end.getTime() <= currentStart.getTime()) {
currentStart = null;
currentType = null;
sourceEventIds = [];
currentPayload = null;
return;
}
spans.push({
type: currentType,
@@ -55,12 +66,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
endedAt: end,
sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert
status: "factual"
status: "factual",
payload: currentPayload,
description: currentPayload?.description || ""
});
currentStart = null;
currentType = null;
sourceEventIds = [];
currentPayload = null;
};
const closeOpenSpanAsRunning = () => {
@@ -72,12 +86,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
endedAt: null,
sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert
status: "factual"
status: "factual",
payload: currentPayload,
description: currentPayload?.description || ""
});
currentStart = null;
currentType = null;
sourceEventIds = [];
currentPayload = null;
};
for (const event of events) {
@@ -96,6 +113,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "WORKING";
currentStart = event.eventtime;
currentType = "work";
currentPayload = event.payload || null;
break;
case "pause_start":
@@ -104,6 +122,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "PAUSED";
currentStart = event.eventtime;
currentType = "pause";
currentPayload = event.payload || null;
}
break;
@@ -113,6 +132,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "WORKING";
currentStart = event.eventtime;
currentType = "work";
currentPayload = event.payload || null;
}
break;
@@ -140,6 +160,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "ABSENT";
currentStart = event.eventtime;
currentType = newType;
currentPayload = event.payload || null;
break;
case "vacation_end":
@@ -162,4 +183,4 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
}
return spans;
}
}

View File

@@ -1,7 +1,7 @@
// src/services/loadValidEvents.ts
import { stafftimeevents } from "../../../db/schema";
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
import {sql, and, eq, gte, lte, inArray, asc} from "drizzle-orm";
import { FastifyInstance } from "fastify";
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
@@ -12,11 +12,43 @@ export type TimeEvent = {
id: string;
eventtype: string;
eventtime: Date;
actoruser_id: string;
actoruser_id?: string;
related_event_id: string | null;
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
payload?: Record<string, any> | null;
created_at?: Date | null;
};
const EVENT_TYPE_ORDER: Record<string, number> = {
auto_stop: 10,
work_end: 10,
pause_end: 10,
vacation_end: 10,
sick_end: 10,
overtime_compensation_end: 10,
work_start: 20,
pause_start: 20,
vacation_start: 20,
sick_start: 20,
overtime_compensation_start: 20,
submitted: 30,
approved: 30,
rejected: 30,
invalidated: 40,
};
export function compareTimeEvents(a: TimeEvent, b: TimeEvent) {
const eventTimeDiff = a.eventtime.getTime() - b.eventtime.getTime();
if (eventTimeDiff !== 0) return eventTimeDiff;
const typeOrderDiff = (EVENT_TYPE_ORDER[a.eventtype] ?? 999) - (EVENT_TYPE_ORDER[b.eventtype] ?? 999);
if (typeOrderDiff !== 0) return typeOrderDiff;
const createdAtDiff = (a.created_at?.getTime() ?? 0) - (b.created_at?.getTime() ?? 0);
if (createdAtDiff !== 0) return createdAtDiff;
return a.id.localeCompare(b.id);
}
export async function loadValidEvents(
server: FastifyInstance,
tenantId: number,
@@ -62,10 +94,9 @@ export async function loadValidEvents(
)
)
.orderBy(
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
baseEvents.eventtime,
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
baseEvents.id
asc(baseEvents.eventtime),
asc(baseEvents.created_at),
asc(baseEvents.id)
);
// Mapping auf den sauberen TimeEvent Typ
@@ -73,8 +104,10 @@ export async function loadValidEvents(
id: e.id,
eventtype: e.eventtype,
eventtime: e.eventtime,
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
// ...
actoruser_id: e.actoruser_id,
related_event_id: e.related_event_id,
payload: e.payload,
created_at: e.created_at,
})) as TimeEvent[];
}
@@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) {
)
)
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
.orderBy(stafftimeevents.eventtime);
.orderBy(
asc(stafftimeevents.eventtime),
asc(stafftimeevents.created_at),
asc(stafftimeevents.id)
);
return adminEvents;
}
return adminEvents as TimeEvent[];
}

View File

@@ -2,16 +2,67 @@ import { FastifyInstance } from "fastify"
import fp from "fastify-plugin"
import jwt from "jsonwebtoken"
import { secrets } from "../utils/secrets"
import { createHash } from "node:crypto"
import {
authUserRoles,
authRolePermissions,
authUsers,
m2mApiKeys,
} from "../../db/schema"
import { eq, and } from "drizzle-orm"
import { eq, and, inArray } from "drizzle-orm"
export default fp(async (server: FastifyInstance) => {
const hashApiKey = (apiKey: string) =>
createHash("sha256").update(apiKey, "utf8").digest("hex")
const isMcpRoute = (url: string) =>
url === "/mcp" ||
url.startsWith("/mcp/") ||
url === "/api/mcp" ||
url.startsWith("/api/mcp/")
const authenticateMcpApiKey = async (apiKey: string) => {
if (!apiKey.startsWith("fedeo_mcp_")) return false
const keyHash = hashApiKey(apiKey)
const rows = await server.db
.select({
id: m2mApiKeys.id,
tenantId: m2mApiKeys.tenantId,
userId: m2mApiKeys.userId,
active: m2mApiKeys.active,
expiresAt: m2mApiKeys.expiresAt,
userEmail: authUsers.email,
isAdmin: authUsers.is_admin,
})
.from(m2mApiKeys)
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and(
eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true)
))
.limit(1)
const key = rows[0]
if (!key) return false
if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) return false
await server.db
.update(m2mApiKeys)
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
.where(eq(m2mApiKeys.id, key.id))
return {
user_id: key.userId,
email: key.userEmail,
tenant_id: key.tenantId,
is_admin: Boolean(key.isAdmin),
}
}
server.addHook("preHandler", async (req, reply) => {
// 1⃣ Token aus Header oder Cookie lesen
const cookieToken = req.cookies?.token
@@ -30,11 +81,31 @@ export default fp(async (server: FastifyInstance) => {
}
try {
// 2⃣ JWT verifizieren
const payload = jwt.verify(token, secrets.JWT_SECRET!) as {
// 2⃣ JWT verifizieren oder für MCP dauerhaften Token akzeptieren
let payload: {
user_id: string
email: string
tenant_id: number | null
is_admin?: boolean
}
try {
payload = jwt.verify(token, secrets.JWT_SECRET!) as {
user_id: string
email: string
tenant_id: number | null
}
} catch (jwtError) {
const mcpPayload = isMcpRoute(req.url)
? await authenticateMcpApiKey(token)
: false
if (!mcpPayload) {
throw jwtError
}
payload = mcpPayload
;(req as any).mcpTokenAuth = true
}
if (!payload?.user_id) {
@@ -44,15 +115,19 @@ export default fp(async (server: FastifyInstance) => {
// Payload an Request hängen
req.user = payload
const [currentUser] = await server.db
.select({
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.id, payload.user_id))
.limit(1)
if (typeof payload.is_admin === "boolean") {
req.user.is_admin = payload.is_admin
} else {
const [currentUser] = await server.db
.select({
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.id, payload.user_id))
.limit(1)
req.user.is_admin = Boolean(currentUser?.is_admin)
req.user.is_admin = Boolean(currentUser?.is_admin)
}
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
if (!req.user.tenant_id) {
@@ -63,10 +138,12 @@ export default fp(async (server: FastifyInstance) => {
const userId = req.user.user_id
// --------------------------------------------------------
// 3⃣ Rolle des Nutzers im Tenant holen
// 3⃣ Rollen des Nutzers im Tenant holen
// --------------------------------------------------------
const roleRows = await server.db
.select()
.select({
role_id: authUserRoles.role_id,
})
.from(authUserRoles)
.where(
and(
@@ -74,7 +151,6 @@ export default fp(async (server: FastifyInstance) => {
eq(authUserRoles.tenant_id, tenantId)
)
)
.limit(1)
if (roleRows.length === 0) {
if (req.user.is_admin) {
@@ -89,22 +165,22 @@ export default fp(async (server: FastifyInstance) => {
.send({ error: "No role assigned for this tenant" })
}
const roleId = roleRows[0].role_id
const roleIds = Array.from(new Set(roleRows.map((role) => role.role_id)))
// --------------------------------------------------------
// 4⃣ Berechtigungen der Rolle laden
// 4⃣ Berechtigungen der Rollen laden
// --------------------------------------------------------
const permissionRows = await server.db
.select()
.from(authRolePermissions)
.where(eq(authRolePermissions.role_id, roleId))
.where(inArray(authRolePermissions.role_id, roleIds))
const permissions = permissionRows.map((p) => p.permission)
const permissions = Array.from(new Set(permissionRows.map((p) => p.permission)))
// --------------------------------------------------------
// 5⃣ An Request hängen für spätere Nutzung
// --------------------------------------------------------
req.role = roleId
req.role = roleIds[0]
req.permissions = permissions
req.hasPermission = (perm: string) => permissions.includes(perm)

View File

@@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm";
import {
authTenantUsers,
authProfiles,
customers,
authRoles,
authUserRoles,
authUsers,
@@ -12,6 +13,10 @@ import {
tenants,
} from "../../db/schema";
import { generateRandomPassword, hashPassword } from "../utils/password";
import { sendMail } from "../utils/mailer";
import { ensureTenantBaseData } from "../modules/bootstrap.service";
import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport";
import type { TenantFullExport } from "../utils/tenantFullExport";
export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => {
@@ -40,36 +45,42 @@ export default async function adminRoutes(server: FastifyInstance) {
name: "Rechnungen",
color: "#16a34a",
createdDocumentType: "invoices",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Angebote",
color: "#2563eb",
createdDocumentType: "quotes",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Auftragsbestätigungen",
color: "#7c3aed",
createdDocumentType: "confirmationOrders",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Lieferscheine",
color: "#ea580c",
createdDocumentType: "deliveryNotes",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Eingangsrechnungen",
color: "#dc2626",
incomingDocumentType: "invoices",
isSystemUsed: true,
},
{
tenant: tenantId,
name: "Mahnungen",
color: "#b91c1c",
incomingDocumentType: "reminders",
isSystemUsed: true,
},
])
.returning({
@@ -241,6 +252,7 @@ export default async function adminRoutes(server: FastifyInstance) {
const [currentUser] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
is_admin: authUsers.is_admin,
})
.from(authUsers)
@@ -255,6 +267,33 @@ export default async function adminRoutes(server: FastifyInstance) {
return currentUser;
};
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
const existingRoles = await server.db
.select({
id: authRoles.id,
name: authRoles.name,
})
.from(authRoles)
.where(eq(authRoles.tenant_id, tenantId));
const portalRole = existingRoles.find((role) => role.name === "Kundenportal");
if (portalRole) return portalRole.id;
const [createdRole] = await server.db
.insert(authRoles)
.values({
name: "Kundenportal",
description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer",
tenant_id: tenantId,
created_by: createdBy,
})
.returning({
id: authRoles.id,
});
return createdRole.id;
};
// -------------------------------------------------------------
// GET /admin/overview
// -------------------------------------------------------------
@@ -422,6 +461,343 @@ export default async function adminRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// POST /admin/profiles/:profileId/create-user
// -------------------------------------------------------------
server.post("/admin/profiles/:profileId/create-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const { profileId } = req.params as { profileId: string };
const body = req.body as { email?: string };
const email = body.email?.trim().toLowerCase();
if (!email) {
return reply.code(400).send({ error: "email required" });
}
const [profile] = await server.db
.select({
id: authProfiles.id,
tenant_id: authProfiles.tenant_id,
user_id: authProfiles.user_id,
first_name: authProfiles.first_name,
last_name: authProfiles.last_name,
email: authProfiles.email,
})
.from(authProfiles)
.where(eq(authProfiles.id, profileId))
.limit(1);
if (!profile) {
return reply.code(404).send({ error: "Profile not found" });
}
if (profile.user_id) {
return reply.code(409).send({ error: "Profile already linked to a user" });
}
const existingUsers = await server.db
.select({ id: authUsers.id })
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
if (existingUsers.length) {
return reply.code(409).send({ error: "User with this email already exists" });
}
const initialPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(initialPassword);
const result = await server.db.transaction(async (tx) => {
const [createdUser] = await tx
.insert(authUsers)
.values({
email,
passwordHash,
is_admin: false,
multiTenant: true,
must_change_password: true,
updatedAt: new Date(),
})
.returning({
id: authUsers.id,
email: authUsers.email,
must_change_password: authUsers.must_change_password,
is_admin: authUsers.is_admin,
multiTenant: authUsers.multiTenant,
created_at: authUsers.created_at,
});
await tx
.insert(authTenantUsers)
.values({
tenant_id: profile.tenant_id,
user_id: createdUser.id,
created_by: currentUser.id,
});
const [updatedProfile] = await tx
.update(authProfiles)
.set({
user_id: createdUser.id,
email,
})
.where(eq(authProfiles.id, profile.id))
.returning({
id: authProfiles.id,
tenant_id: authProfiles.tenant_id,
user_id: authProfiles.user_id,
first_name: authProfiles.first_name,
last_name: authProfiles.last_name,
email: authProfiles.email,
});
return {
user: createdUser,
profile: updatedProfile,
};
});
return {
...result,
initialPassword,
};
} catch (err) {
console.error("ERROR /admin/profiles/:profileId/create-user:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const tenantId = Number(req.user?.tenant_id);
const { customerId } = req.params as { customerId: string };
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
const [tenantRecord] = await server.db
.select({
id: tenants.id,
name: tenants.name,
portalDomain: tenants.portalDomain,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
const [customerRecord] = await server.db
.select()
.from(customers)
.where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId)))
.limit(1);
if (!customerRecord) {
return reply.code(404).send({ error: "Customer not found" });
}
const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record<string, any> : {};
const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase();
if (!email) {
return reply.code(400).send({ error: "Customer has no email address" });
}
const generatedPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(generatedPassword);
const [existingUser] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
const derivedName = deriveNameFromEmail(email);
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
let userId = existingUser?.id || null;
let createdNewUser = false;
if (existingUser) {
const [existingProfile] = await server.db
.select({
id: authProfiles.id,
customer_for_portal: authProfiles.customer_for_portal,
})
.from(authProfiles)
.where(and(
eq(authProfiles.user_id, existingUser.id),
eq(authProfiles.tenant_id, tenantId)
))
.limit(1);
if (existingUser.is_admin) {
return reply.code(409).send({ error: "Email address is already used by an admin user" });
}
if (!existingProfile) {
return reply.code(409).send({ error: "Email address is already used by another user" });
}
if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) {
return reply.code(409).send({ error: "Email address is already assigned to another portal customer" });
}
await server.db
.update(authUsers)
.set({
passwordHash,
must_change_password: true,
multiTenant: false,
updatedAt: new Date(),
})
.where(eq(authUsers.id, existingUser.id));
userId = existingUser.id;
} else {
const [createdUser] = await server.db
.insert(authUsers)
.values({
email,
passwordHash,
is_admin: false,
multiTenant: false,
must_change_password: true,
updatedAt: new Date(),
})
.returning({
id: authUsers.id,
});
userId = createdUser.id;
createdNewUser = true;
}
const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id);
const existingMemberships = await server.db
.select()
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.user_id, userId!),
eq(authTenantUsers.tenant_id, tenantId)
))
.limit(1);
if (!existingMemberships.length) {
await server.db
.insert(authTenantUsers)
.values({
tenant_id: tenantId,
user_id: userId!,
created_by: currentUser.id,
});
}
const existingPortalRoleAssignment = await server.db
.select()
.from(authUserRoles)
.where(and(
eq(authUserRoles.user_id, userId!),
eq(authUserRoles.tenant_id, tenantId),
eq(authUserRoles.role_id, portalRoleId)
))
.limit(1);
if (!existingPortalRoleAssignment.length) {
await server.db
.insert(authUserRoles)
.values({
user_id: userId!,
tenant_id: tenantId,
role_id: portalRoleId,
created_by: currentUser.id,
});
}
const [existingTenantProfile] = await server.db
.select({
id: authProfiles.id,
user_id: authProfiles.user_id,
customer_for_portal: authProfiles.customer_for_portal,
})
.from(authProfiles)
.where(and(
eq(authProfiles.user_id, userId!),
eq(authProfiles.tenant_id, tenantId)
))
.limit(1);
if (existingTenantProfile) {
await server.db
.update(authProfiles)
.set({
first_name: firstName,
last_name: lastName,
email,
customer_for_portal: customerRecord.id,
active: true,
})
.where(eq(authProfiles.id, existingTenantProfile.id));
} else {
await server.db
.insert(authProfiles)
.values({
user_id: userId!,
tenant_id: tenantId,
first_name: firstName,
last_name: lastName,
email,
customer_for_portal: customerRecord.id,
active: true,
});
}
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
const mailResult = await sendMail(
email,
`FEDEO | Einladung ins Kundenportal`,
`
<p>Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},</p>
<p>für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.</p>
<p><strong>E-Mail:</strong> ${email}</p>
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
`
);
if (!mailResult.success) {
return reply.code(500).send({ error: "Invitation email could not be sent" });
}
return {
success: true,
createdNewUser,
email,
initialPassword: generatedPassword,
portalUrl,
};
} catch (err) {
console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// POST /admin/tenants
// -------------------------------------------------------------
@@ -459,6 +835,7 @@ export default async function adminRoutes(server: FastifyInstance) {
});
await createTenantSeeds(createdTenant.id, currentUser.id);
await ensureTenantBaseData(server, createdTenant.id, currentUser.id);
return { tenant: createdTenant };
} catch (err) {
@@ -561,6 +938,96 @@ export default async function adminRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// GET /admin/tenants/:tenant_id/export
// -------------------------------------------------------------
server.get("/admin/tenants/:tenant_id/export", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const { tenant_id } = req.params as { tenant_id: string };
const tenantId = Number(tenant_id);
if (!tenantId) {
return reply.code(400).send({ error: "tenant_id required" });
}
const exportData = await buildTenantFullExport(server, tenantId);
const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId)
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
const filename = `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return reply.send(exportData);
} catch (err) {
console.error("ERROR /admin/tenants/:tenant_id/export:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// POST /admin/tenant-imports
// -------------------------------------------------------------
server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
const exportData = "format" in body ? body : body.exportData;
const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null;
if (!exportData) {
return reply.code(400).send({ error: "exportData required" });
}
const result = await importTenantFullExport(server, exportData, { targetTenantId });
const fallbackName = deriveNameFromEmail(currentUser.email);
await server.db
.insert(authTenantUsers)
.values({
tenant_id: result.tenantId,
user_id: currentUser.id,
created_by: currentUser.id,
})
.onConflictDoNothing();
const [existingAdminProfile] = await server.db
.select({ id: authProfiles.id })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, result.tenantId),
eq(authProfiles.user_id, currentUser.id)
))
.limit(1);
if (!existingAdminProfile) {
await server.db
.insert(authProfiles)
.values({
tenant_id: result.tenantId,
user_id: currentUser.id,
first_name: fallbackName.first_name,
last_name: fallbackName.last_name,
email: currentUser.email,
active: true,
});
}
return {
success: true,
...result,
};
} catch (err: any) {
console.error("ERROR /admin/tenant-imports:", err);
return reply.code(500).send({ error: err?.message || "Internal Server Error" });
}
});
// -------------------------------------------------------------
// PUT /admin/users/:user_id/access
// -------------------------------------------------------------

View File

@@ -9,6 +9,7 @@ import {
authRolePermissions,
} from "../../../db/schema"
import { eq, and, or, isNull } from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
export default async function meRoutes(server: FastifyInstance) {
server.get("/me", async (req, reply) => {
@@ -51,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
id: tenants.id,
name: tenants.name,
short: tenants.short,
hasActiveLicense: tenants.hasActiveLicense,
locked: tenants.locked,
features: tenants.features,
extraModules: tenants.extraModules,
@@ -89,7 +91,8 @@ export default async function meRoutes(server: FastifyInstance) {
)
.limit(1)
profile = profileResult?.[0] ?? null
const enrichedProfiles = await enrichProfilesWithBranches(server, profileResult)
profile = enrichedProfiles?.[0] ?? null
}
// ----------------------------------------------------

View File

@@ -10,11 +10,14 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import {
bankrequisitions,
bankaccounts,
bankstatements,
accounts,
createddocuments,
customers,
entitybankaccounts,
incominginvoices,
ownaccounts,
statementallocations,
vendors,
} from "../../db/schema"
@@ -22,10 +25,355 @@ import {
import {
eq,
and,
isNull,
aliasedTable,
desc,
} from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) {
const CASHBOOK_BANK_ID = "fedeo-cashbook"
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
const ContraCustomers = aliasedTable(customers, "contra_customers")
const ContraVendors = aliasedTable(vendors, "contra_vendors")
const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts")
const ManualInvoices = aliasedTable(incominginvoices, "manual_invoices")
const ManualInvoiceVendors = aliasedTable(vendors, "manual_invoice_vendors")
const normalizeManualSide = (payload: any, keys: string[]) =>
keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "")
const cashbookAccountFilter = (tenantId: number) => and(
eq(bankaccounts.tenant, tenantId),
eq(bankaccounts.bankId, CASHBOOK_BANK_ID),
eq(bankaccounts.archived, false)
)
const buildCashbookCounterPayload = (type: string, id: any) => {
const numericId = type === "ownaccount" ? id : Number(id)
if (!id || (type !== "ownaccount" && !Number.isFinite(numericId))) return null
if (type === "account") return { account: numericId }
if (type === "customer") return { customer: numericId }
if (type === "vendor") return { vendor: numericId }
if (type === "ownaccount") return { ownaccount: numericId }
if (type === "incominginvoice") return { incominginvoice: numericId }
return null
}
const prepareStatementAllocationPayload = (payload: any) => {
const next = { ...payload }
const isManualBooking = !next.bankstatement
if (!isManualBooking) {
next.manualBookingDate = null
next.contraAccount = null
next.contraCustomer = null
next.contraVendor = null
next.contraOwnaccount = null
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
next.manualInvoiceSide = null
return { data: next }
}
const debitKeys = ["account", "customer", "vendor", "ownaccount"]
const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"]
const hasManualInvoice = next.incominginvoice !== null && next.incominginvoice !== undefined && next.incominginvoice !== ""
const debitSide = normalizeManualSide(next, debitKeys)
const creditSide = normalizeManualSide(next, creditKeys)
if (hasManualInvoice) {
if (next.manualInvoiceSide === "debit") debitSide.push("incominginvoice")
else if (next.manualInvoiceSide === "credit") creditSide.push("incominginvoice")
else return { error: "Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein." }
} else {
next.manualInvoiceSide = null
}
if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) {
return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." }
}
if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) {
return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." }
}
if (debitSide.length !== 1 || creditSide.length !== 1) {
return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." }
}
next.amount = Math.abs(Number(next.amount))
next.bankstatement = null
next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD")
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
return { data: next }
}
server.get("/banking/cashbooks", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const rows = await server.db
.select()
.from(bankaccounts)
.where(cashbookAccountFilter(req.user.tenant_id))
.orderBy(bankaccounts.name)
return reply.send(rows)
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to load cashbooks" })
}
})
server.post("/banking/cashbooks", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const body = req.body as {
name?: string
datevNumber?: string
openingBalance?: number
}
const name = String(body.name || "").trim()
const datevNumber = String(body.datevNumber || "").trim()
const openingBalance = Number(body.openingBalance || 0)
if (!name) return reply.code(400).send({ error: "Bitte eine Bezeichnung für die Kasse angeben." })
if (!datevNumber) return reply.code(400).send({ error: "Bitte eine Kontennummer für die Kasse angeben." })
if (!Number.isFinite(openingBalance)) return reply.code(400).send({ error: "Der Anfangsbestand ist ungültig." })
const uniquePart = `${req.user.tenant_id}-${Date.now()}`
const inserted = await server.db.insert(bankaccounts).values({
name,
iban: `CASH-${uniquePart}`,
tenant: req.user.tenant_id,
bankId: CASHBOOK_BANK_ID,
ownerName: name,
accountId: `cashbook-${uniquePart}`,
balance: openingBalance,
datevNumber,
updatedBy: req.user.user_id,
}).returning()
const createdRecord = inserted[0]
return reply.send(createdRecord)
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to create cashbook" })
}
})
server.patch("/banking/cashbooks/:id/archive", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const updated = await server.db.update(bankaccounts)
.set({
archived: true,
updatedAt: new Date(),
updatedBy: req.user.user_id,
})
.where(and(
eq(bankaccounts.id, Number(id)),
eq(bankaccounts.tenant, req.user.tenant_id),
eq(bankaccounts.bankId, CASHBOOK_BANK_ID)
))
.returning()
if (!updated[0]) return reply.code(404).send({ error: "Cashbook not found" })
return reply.send(updated[0])
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to archive cashbook" })
}
})
server.get("/banking/cashbooks/:id/bookings", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const cashbookId = Number(id)
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
const rows = await server.db.select({
statement: bankstatements,
allocation: statementallocations,
account: accounts,
customer: customers,
vendor: vendors,
ownaccount: ownaccounts,
incominginvoice: ManualInvoices,
incominginvoiceVendor: ManualInvoiceVendors,
})
.from(bankstatements)
.leftJoin(statementallocations, eq(statementallocations.bankstatement, bankstatements.id))
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
.where(and(
eq(bankstatements.tenant, req.user.tenant_id),
eq(bankstatements.account, cashbookId),
eq(bankstatements.archived, false)
))
.orderBy(desc(bankstatements.date), desc(bankstatements.createdAt))
return reply.send(rows.map((row) => ({
...row.statement,
allocation: row.allocation,
account: row.account,
customer: row.customer,
vendor: row.vendor,
ownaccount: row.ownaccount,
incominginvoice: row.incominginvoice ? {
...row.incominginvoice,
vendor: row.incominginvoiceVendor,
} : null,
})))
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to load cashbook bookings" })
}
})
server.post("/banking/cashbooks/:id/bookings", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const cashbookId = Number(id)
const body = req.body as {
date?: string
amount?: number
direction?: "income" | "expense"
counterType?: string
counterId?: string | number
description?: string
datevTaxKey?: string | null
}
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
if (!body.date || !dayjs(body.date).isValid()) return reply.code(400).send({ error: "Bitte ein gültiges Buchungsdatum angeben." })
if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." })
if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." })
const cashbook = await server.db.select().from(bankaccounts).where(and(
eq(bankaccounts.id, cashbookId),
eq(bankaccounts.tenant, req.user.tenant_id),
eq(bankaccounts.bankId, CASHBOOK_BANK_ID),
eq(bankaccounts.archived, false)
)).limit(1)
if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." })
const signedAmount = body.direction === "income"
? Math.abs(Number(body.amount))
: -Math.abs(Number(body.amount))
const description = String(body.description || "").trim() || (body.direction === "income" ? "Bareinnahme" : "Barausgabe")
const created = await server.db.transaction(async (tx) => {
const insertedStatements = await tx.insert(bankstatements).values({
account: cashbookId,
date: dayjs(body.date).format("YYYY-MM-DD"),
amount: signedAmount,
tenant: req.user.tenant_id,
text: description,
currency: "EUR",
credName: body.direction === "income" ? cashbook[0].name : description,
debName: body.direction === "expense" ? cashbook[0].name : description,
updatedBy: req.user.user_id,
}).returning()
const statement = insertedStatements[0]
const insertedAllocations = await tx.insert(statementallocations).values({
bankstatement: statement.id,
amount: signedAmount,
tenant: req.user.tenant_id,
description,
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
...counterPayload,
}).returning()
return {
statement,
allocation: insertedAllocations[0],
}
})
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(created.statement.id),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: created,
text: "Kassenbuchung erstellt",
})
return reply.send(created)
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to create cashbook booking" })
}
})
server.delete("/banking/cashbook-bookings/:id", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const statementId = Number(id)
if (!Number.isFinite(statementId)) return reply.code(400).send({ error: "Ungültige Buchung." })
const records = await server.db.select({
statement: bankstatements,
cashbook: bankaccounts,
})
.from(bankstatements)
.innerJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
.where(and(
eq(bankstatements.id, statementId),
eq(bankstatements.tenant, req.user.tenant_id),
eq(bankaccounts.bankId, CASHBOOK_BANK_ID)
))
.limit(1)
if (!records[0]) return reply.code(404).send({ error: "Kassenbuchung nicht gefunden." })
await server.db.transaction(async (tx) => {
await tx.delete(statementallocations).where(eq(statementallocations.bankstatement, statementId))
await tx.delete(bankstatements).where(eq(bankstatements.id, statementId))
})
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: statementId,
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: records[0].statement,
newVal: null,
text: "Kassenbuchung gelöscht",
})
return reply.send({ success: true })
} catch (err) {
server.log.error(err)
return reply.code(500).send({ error: "Failed to delete cashbook booking" })
}
})
const normalizeIban = (value?: string | null) =>
String(value || "").replace(/\s+/g, "").toUpperCase()
@@ -677,6 +1025,64 @@ export default async function bankingRoutes(server: FastifyInstance) {
}
})
// ------------------------------------------------------------------
// 📒 List Manual Statement Allocations
// ------------------------------------------------------------------
server.get("/banking/manual-bookings", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const rows = await server.db.select({
allocation: statementallocations,
account: accounts,
customer: customers,
vendor: vendors,
ownaccount: ownaccounts,
contraAccount: ContraAccounts,
contraCustomer: ContraCustomers,
contraVendor: ContraVendors,
contraOwnaccount: ContraOwnaccounts,
incominginvoice: ManualInvoices,
incominginvoiceVendor: ManualInvoiceVendors,
})
.from(statementallocations)
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id))
.leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id))
.leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id))
.leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id))
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
.where(and(
eq(statementallocations.tenant, req.user.tenant_id),
eq(statementallocations.archived, false),
isNull(statementallocations.bankstatement)
))
return reply.send(rows.map((row) => ({
...row.allocation,
account: row.account,
customer: row.customer,
vendor: row.vendor,
ownaccount: row.ownaccount,
contraAccount: row.contraAccount,
contraCustomer: row.contraCustomer,
contraVendor: row.contraVendor,
contraOwnaccount: row.contraOwnaccount,
incominginvoice: row.incominginvoice ? {
...row.incominginvoice,
vendor: row.incominginvoiceVendor,
} : null,
})))
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to load manual bookings" })
}
})
// ------------------------------------------------------------------
// 💰 Create Statement Allocation
@@ -686,9 +1092,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { data: payload } = req.body as { data: any }
const prepared = prepareStatementAllocationPayload(payload)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
const inserted = await server.db.insert(statementallocations).values({
...payload,
...prepared.data,
tenant: req.user.tenant_id
}).returning()
@@ -720,16 +1128,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
}
}
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(createdRecord.bankstatement),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: createdRecord,
text: "Buchung erstellt",
})
if (createdRecord.bankstatement) {
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(createdRecord.bankstatement),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: createdRecord,
text: "Buchung erstellt",
})
}
return reply.send(createdRecord)
@@ -763,16 +1173,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
.delete(statementallocations)
.where(eq(statementallocations.id, id))
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(old.bankstatement),
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: old,
newVal: null,
text: "Buchung gelöscht",
})
if (old.bankstatement) {
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(old.bankstatement),
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: old,
newVal: null,
text: "Buchung gelöscht",
})
}
return reply.send({ success: true })

View File

@@ -0,0 +1,793 @@
import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify"
import multipart from "@fastify/multipart"
import { and, eq, inArray, ne } from "drizzle-orm"
import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema"
import { matrixService } from "../modules/matrix.service"
import { NotificationService, UserDirectory } from "../modules/notification.service"
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
const rows = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
return rows[0] || null
}
type ChatRecipient = {
userId: string
email?: string | null
firstName?: string | null
lastName?: string | null
fullName?: string | null
}
export default async function communicationRoutes(server: FastifyInstance) {
await server.register(multipart, {
limits: { fileSize: 25 * 1024 * 1024 },
})
const matrix = matrixService(server)
const notifications = new NotificationService(server, getUserDirectory)
const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => {
req.log.error(err)
return reply
.code(err.statusCode || 500)
.send({ error: err.message || fallbackMessage })
}
const roomOptionsFromRequest = (req: any) => {
const params = req.params as { roomKey?: string }
const body = (req.body || {}) as {
key?: string
name?: string
topic?: string
type?: string
entityType?: string | null
entityId?: number | null
entityUuid?: string | null
}
return {
key: params.roomKey || body.key,
name: body.name,
topic: body.topic,
type: body.type,
entityType: body.entityType,
entityId: body.entityId,
entityUuid: body.entityUuid,
}
}
const projectRoomKey = (projectId: number) => `project_${projectId}`
const directRoomKey = (firstUserId: string, secondUserId: string) => {
const hash = createHash("sha256")
.update([firstUserId, secondUserId].sort().join(":"))
.digest("hex")
.slice(0, 16)
return `direct_${hash}`
}
const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => {
const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ")
return name || user.email || "Benutzer"
}
const getTenantRecipients = async (tenantId: number, senderUserId: string) => {
return await server.db
.select({
userId: authTenantUsers.user_id,
email: authUsers.email,
firstName: authProfiles.first_name,
lastName: authProfiles.last_name,
fullName: authProfiles.full_name,
})
.from(authTenantUsers)
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, tenantId)
))
.where(and(
eq(authTenantUsers.tenant_id, tenantId),
ne(authTenantUsers.user_id, senderUserId)
))
}
const getSenderName = async (tenantId: number, senderUserId: string) => {
const [sender] = await server.db
.select({
email: authUsers.email,
firstName: authProfiles.first_name,
lastName: authProfiles.last_name,
fullName: authProfiles.full_name,
})
.from(authUsers)
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authUsers.id),
eq(authProfiles.tenant_id, tenantId)
))
.where(eq(authUsers.id, senderUserId))
.limit(1)
return sender ? displayUserName(sender) : "FEDEO"
}
const mentionAliasesForUser = (user: ChatRecipient) => {
const name = displayUserName(user)
return Array.from(new Set([
name,
user.fullName,
[user.firstName, user.lastName].filter(Boolean).join(" "),
user.firstName,
user.email,
].filter(Boolean).map((value) => String(value).toLowerCase())))
}
const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => {
const normalizedText = text.toLowerCase()
return recipients
.filter((recipient) => mentionAliasesForUser(recipient).some((alias) =>
normalizedText.includes(`@${alias}`)
))
.map((recipient) => recipient.userId)
}
const chatMessageRecipients = async (
tenantId: number,
senderUserId: string,
room: any,
text: string
) => {
const recipients = await getTenantRecipients(tenantId, senderUserId)
const mentioned = new Set(mentionedRecipientIds(text, recipients))
const directRecipients = new Set<string>()
if (room?.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
directRecipients.add(room.entityUuid)
}
return recipients
.filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId))
.map((recipient) => ({
...recipient,
mentioned: mentioned.has(recipient.userId),
direct: directRecipients.has(recipient.userId),
}))
}
const notifyUsersAboutChatMessage = async (req: any, room: any, message: any, text: string) => {
if (!req.user.tenant_id) return
try {
const recipients = await chatMessageRecipients(req.user.tenant_id, req.user.user_id, room, text)
if (!recipients.length) return
const senderName = await getSenderName(req.user.tenant_id, req.user.user_id)
const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text
for (const recipient of recipients) {
await notifications.trigger({
tenantId: req.user.tenant_id,
userId: recipient.userId,
eventType: "communication.message.new",
title: recipient.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`,
message: preview,
payload: {
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
roomKey: room.key,
roomName: room.name,
roomType: room.type,
messageId: message.id,
mentioned: recipient.mentioned,
direct: recipient.direct,
},
channels: ["inapp", "push"],
})
}
} catch (err) {
req.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden")
}
}
const unreadChatNotifications = async (tenantId: number, userId: string) => {
return await server.db
.select({
id: notificationsItems.id,
payload: notificationsItems.payload,
})
.from(notificationsItems)
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
eq(notificationsItems.eventType, "communication.message.new"),
eq(notificationsItems.channel, "inapp"),
ne(notificationsItems.status, "read")
))
}
const markRoomNotificationsRead = async (tenantId: number, userId: string, roomKey: string) => {
const rows = await unreadChatNotifications(tenantId, userId)
const ids = rows
.filter((row) => (row.payload as any)?.roomKey === roomKey)
.map((row) => row.id)
if (!ids.length) return { read: 0 }
await server.db
.update(notificationsItems)
.set({ readAt: new Date(), status: "read" })
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
inArray(notificationsItems.id, ids)
))
return { read: ids.length }
}
const uploadedAttachmentFromRequest = async (req: any) => {
const data = await req.file()
if (!data?.file) {
throw Object.assign(
new Error("Keine Datei hochgeladen"),
{ statusCode: 400 }
)
}
const buffer = await data.toBuffer()
return {
buffer,
filename: data.filename || "Anhang",
mimeType: data.mimetype || "application/octet-stream",
size: buffer.length,
}
}
const callModeFromRequest = (req: any): "audio" | "video" => {
const body = (req.body || {}) as { mode?: string }
return body.mode === "audio" ? "audio" : "video"
}
const notifyTenantUsersAboutCall = async (req: any, room: { key?: string; name?: string }, mode: "audio" | "video") => {
if (!req.user.tenant_id) return
try {
const recipientRows = await server.db
.select({ userId: authTenantUsers.user_id })
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
ne(authTenantUsers.user_id, req.user.user_id)
))
const userIds = recipientRows.map((row) => row.userId)
if (!userIds.length) return
await notifications.trigger({
tenantId: req.user.tenant_id,
userIds,
eventType: "communication.call.started",
title: mode === "audio" ? "Audioanruf gestartet" : "Videokonferenz gestartet",
message: `${room.name || room.key || "Ein Chatraum"} hat eine laufende Besprechung.`,
payload: {
link: "/communication/chat",
roomKey: room.key,
roomName: room.name,
mode,
},
channels: ["inapp", "push"],
})
} catch (err) {
req.log.error({ err }, "Call-Benachrichtigung konnte nicht ausgelöst werden")
}
}
server.get("/communication/matrix/status", async () => {
return matrix.getStatus()
})
server.get("/communication/matrix/me", async (req) => {
const userId = req.user.user_id
return {
matrixUserId: await matrix.matrixUserIdForUser(userId, req.user.tenant_id),
displayName: await matrix.getCurrentUserDisplayName(userId, req.user.tenant_id),
}
})
server.post("/communication/matrix/me/provision", async (req, reply) => {
try {
return await matrix.provisionCurrentUser(req.user.user_id, req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix provisioning failed")
}
})
server.get("/communication/matrix/tenant-space", async (req, reply) => {
try {
return await matrix.getTenantSpaceStatus(req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix tenant space status failed")
}
})
server.post("/communication/matrix/tenant-space/provision", async (req, reply) => {
try {
return await matrix.provisionCurrentTenantSpace(req.user.user_id, req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix tenant space provisioning failed")
}
})
server.get("/communication/matrix/rooms", async (req, reply) => {
try {
return await matrix.listTenantRooms(req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix rooms failed")
}
})
server.get("/communication/matrix/unread", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const rows = await unreadChatNotifications(req.user.tenant_id, req.user.user_id)
const rooms = rows.reduce((acc: Record<string, { count: number; mentions: number }>, row) => {
const payload = row.payload as any
const roomKey = payload?.roomKey
if (!roomKey) return acc
acc[roomKey] = acc[roomKey] || { count: 0, mentions: 0 }
acc[roomKey].count += 1
if (payload.mentioned) {
acc[roomKey].mentions += 1
}
return acc
}, {})
return { rooms }
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix unread state failed")
}
})
server.get("/communication/matrix/media", async (req, reply) => {
try {
const query = req.query as { uri?: string; name?: string }
if (!query.uri) return reply.code(400).send({ error: "Matrix-Media-URI fehlt" })
const media = await matrix.getMediaContent(req.user.user_id, req.user.tenant_id, query.uri)
reply.header("Content-Type", media.contentType)
if (media.contentLength) reply.header("Content-Length", media.contentLength)
if (query.name) {
reply.header("Content-Disposition", `inline; filename="${query.name.replace(/"/g, "")}"`)
}
return reply.send(media.buffer)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix media failed")
}
})
server.get("/communication/matrix/project-rooms", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const [roomsRes, projectRows] = await Promise.all([
matrix.listTenantRooms(req.user.tenant_id),
server.db
.select({
id: projects.id,
name: projects.name,
projectNumber: projects.projectNumber,
profiles: projects.profiles,
})
.from(projects)
.where(and(
eq(projects.tenant, req.user.tenant_id),
eq(projects.archived, false)
))
])
const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room]))
return {
rooms: projectRows.map((project) => {
const key = projectRoomKey(project.id)
const existing = roomsByKey.get(key) as any
return {
...(existing || {}),
key,
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`,
type: "project",
entityType: "project",
entityId: project.id,
exists: Boolean(existing?.exists),
projectId: project.id,
projectNumber: project.projectNumber,
}
})
}
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix project rooms failed")
}
})
server.post("/communication/matrix/project-rooms/:projectId/provision", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { projectId: string }
const projectId = Number(params.projectId)
const [project] = await server.db
.select()
.from(projects)
.where(and(
eq(projects.tenant, req.user.tenant_id),
eq(projects.id, projectId)
))
.limit(1)
if (!project) return reply.code(404).send({ error: "Projekt nicht gefunden" })
const profileIds = (project.profiles || []) as string[]
const profileRows = profileIds.length
? await server.db
.select({ userId: authProfiles.user_id })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, req.user.tenant_id),
inArray(authProfiles.id, profileIds)
))
: []
const inviteUserIds = profileRows.map((profile) => profile.userId).filter(Boolean) as string[]
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
key: projectRoomKey(project.id),
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`,
type: "project",
entityType: "project",
entityId: project.id,
inviteUserIds,
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix project room provisioning failed")
}
})
server.get("/communication/matrix/direct-rooms", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const [roomsRes, userRows] = await Promise.all([
matrix.listTenantRooms(req.user.tenant_id),
server.db
.select({
userId: authTenantUsers.user_id,
email: authUsers.email,
firstName: authProfiles.first_name,
lastName: authProfiles.last_name,
fullName: authProfiles.full_name,
})
.from(authTenantUsers)
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, req.user.tenant_id)
))
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
ne(authTenantUsers.user_id, req.user.user_id)
))
])
const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room]))
return {
rooms: userRows.map((user) => {
const key = directRoomKey(req.user.user_id, user.userId)
const existing = roomsByKey.get(key) as any
const name = displayUserName(user)
return {
...(existing || {}),
key,
name,
topic: `Direktnachricht mit ${name}`,
type: "direct",
entityType: "user",
entityUuid: user.userId,
exists: Boolean(existing?.exists),
userId: user.userId,
email: user.email,
}
})
}
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix direct rooms failed")
}
})
server.post("/communication/matrix/direct-rooms/:userId/provision", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { userId: string }
const [target] = await server.db
.select({
userId: authTenantUsers.user_id,
email: authUsers.email,
firstName: authProfiles.first_name,
lastName: authProfiles.last_name,
fullName: authProfiles.full_name,
})
.from(authTenantUsers)
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, req.user.tenant_id)
))
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
eq(authTenantUsers.user_id, params.userId)
))
.limit(1)
if (!target || target.userId === req.user.user_id) {
return reply.code(404).send({ error: "Benutzer nicht gefunden" })
}
const targetName = displayUserName(target)
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
key: directRoomKey(req.user.user_id, target.userId),
name: targetName,
topic: `Direktnachricht mit ${targetName}`,
type: "direct",
entityType: "user",
entityUuid: target.userId,
inviteUserIds: [target.userId],
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix direct room provisioning failed")
}
})
server.post("/communication/matrix/rooms", async (req, reply) => {
try {
return await matrix.provisionTenantRoom(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
}
})
server.get("/communication/matrix/rooms/general", async (req, reply) => {
try {
return await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room status failed")
}
})
server.post("/communication/matrix/rooms/general/provision", async (req, reply) => {
try {
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
key: "allgemein",
name: "Allgemeiner Chat",
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
}
})
server.get("/communication/matrix/rooms/general/messages", async (req, reply) => {
try {
return await matrix.getGeneralRoomMessages(req.user.user_id, req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix messages failed")
}
})
server.get("/communication/matrix/rooms/general/members", async (req, reply) => {
try {
return await matrix.getGeneralRoomMembers(req.user.user_id, req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix members failed")
}
})
server.post("/communication/matrix/rooms/general/session", async (req, reply) => {
try {
return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, {
key: "allgemein",
name: "Allgemeiner Chat",
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix session failed")
}
})
server.post("/communication/matrix/rooms/general/call-session", async (req, reply) => {
try {
const room = {
key: "allgemein",
name: "Allgemeiner Chat",
}
const session = await matrix.createLiveKitRoomSession(req.user.user_id, req.user.tenant_id, room)
await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req))
return session
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix call session failed")
}
})
server.post("/communication/matrix/rooms/general/members/sync", async (req, reply) => {
try {
return await matrix.syncTenantRoomMembers(req.user.user_id, req.user.tenant_id, {
key: "allgemein",
name: "Allgemeiner Chat",
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member sync failed")
}
})
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
try {
const body = req.body as { text?: string }
const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
await notifyUsersAboutChatMessage(req, room, message, body.text || "")
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message send failed")
}
})
server.post("/communication/matrix/rooms/general/attachments", async (req, reply) => {
try {
const attachment = await uploadedAttachmentFromRequest(req)
const message = await matrix.sendGeneralRoomAttachment(req.user.user_id, req.user.tenant_id, attachment)
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`)
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix attachment send failed")
}
})
server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => {
try {
const params = req.params as { roomKey: string }
return await matrix.getTenantRoomStatus(req.user.tenant_id, params.roomKey)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room status failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/provision", async (req, reply) => {
try {
return await matrix.provisionTenantRoom(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/read", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { roomKey: string }
return await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room read state failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try {
return await matrix.getTenantRoomMessages(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix messages failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
try {
return await matrix.getTenantRoomMembers(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix members failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => {
try {
return await matrix.createElementRoomSession(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix session failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/call-session", async (req, reply) => {
try {
const room = roomOptionsFromRequest(req)
const session = await matrix.createLiveKitRoomSession(
req.user.user_id,
req.user.tenant_id,
room
)
await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req))
return session
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix call session failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/members/sync", async (req, reply) => {
try {
return await matrix.syncTenantRoomMembers(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member sync failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try {
const body = req.body as { text?: string }
const message = await matrix.sendTenantRoomMessage(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
body.text || ""
)
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
await notifyUsersAboutChatMessage(req, room, message, body.text || "")
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message send failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => {
try {
const attachment = await uploadedAttachmentFromRequest(req)
const message = await matrix.sendTenantRoomAttachment(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
attachment
)
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`)
return message
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix attachment send failed")
}
})
}

View File

@@ -67,6 +67,45 @@ const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDat
}
const createSepaExport = async (server: FastifyInstance, req: any, idsToExport: number[], creditorBankaccountId: number) => {
const exportData = await createSEPAExport(server, idsToExport, req.user.tenant_id, creditorBankaccountId)
const fileKey = `${req.user.tenant_id}/exports/SEPA_${dayjs().format("YYYY-MM-DD")}_${randomUUID()}.xml`
await s3.send(
new PutObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
Body: exportData.buffer,
ContentType: "application/xml",
})
)
const url = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
}),
{ expiresIn: 60 * 60 * 24 }
)
const inserted = await server.db
.insert(generatedexports)
.values({
tenantId: req.user.tenant_id,
startDate: exportData.startDate,
endDate: exportData.endDate,
validUntil: dayjs().add(24, "hours").toDate(),
filePath: fileKey,
url,
type: "sepa",
})
.returning()
console.log(inserted[0])
}
export default async function exportRoutes(server: FastifyInstance) {
//Export DATEV
@@ -94,17 +133,24 @@ export default async function exportRoutes(server: FastifyInstance) {
})
server.post("/exports/sepa", async (req, reply) => {
const { idsToExport } = req.body as {
const { idsToExport, creditorBankaccountId } = req.body as {
idsToExport: Array<number>
creditorBankaccountId: number
}
if (!idsToExport?.length || !creditorBankaccountId) {
return reply.send({
success: false,
message: "Belege und Gläubigerkonto sind Pflichtfelder."
})
}
reply.send({success:true})
setImmediate(async () => {
try {
await createSEPAExport(server, idsToExport, req.user.tenant_id)
await createSepaExport(server, req, idsToExport, creditorBankaccountId)
console.log("Job done ✅")
} catch (err) {
console.error("Job failed ❌", err)

View File

@@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files"
import { eq, inArray } from "drizzle-orm"
import { and } from "drizzle-orm"
import {
authProfiles,
files,
createddocuments,
customers
@@ -18,6 +20,55 @@ import {
export default async function fileRoutes(server: FastifyInstance) {
const getPortalCustomerId = async (req: any) => {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
const loadSingleFileForRequest = async (req: any, id: string) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return null
const portalCustomerId = await getPortalCustomerId(req)
if (!portalCustomerId) {
const rows = await server.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.tenant, tenantId)))
return rows[0] || null
}
const rows = await server.db
.select({
file: files,
})
.from(files)
.leftJoin(createddocuments, eq(files.createddocument, createddocuments.id))
.where(and(
eq(files.id, id),
eq(files.tenant, tenantId),
eq(createddocuments.customer, portalCustomerId),
eq(createddocuments.availableInPortal, true)
))
.limit(1)
return rows[0]?.file || null
}
// -------------------------------------------------------------
// MULTIPART INIT
@@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// 🔹 EINZELNE DATEI
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" })
return file
@@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// 1⃣ SINGLE DOWNLOAD
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "File not found" })
const command = new GetObjectCommand({
@@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// SINGLE FILE PRESIGNED URL
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" })
const url = await getSignedUrl(

View File

@@ -3,7 +3,7 @@ import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import dayjs from "dayjs";
@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
import { generateLiquidityForecast } from "../utils/liquidityForecast";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
dayjs.extend(isBetween)
@@ -57,6 +58,42 @@ function resolveGitRoot() {
return null
}
function getDeploymentChangelogFallback() {
const backendPackagePath = path.resolve(process.cwd(), "package.json")
let version = "unbekannt"
if (existsSync(backendPackagePath)) {
try {
const packageJson = JSON.parse(readFileSync(backendPackagePath, "utf-8"))
version = packageJson?.version || version
} catch (err) {
console.error("Could not read backend package.json for changelog fallback", err)
}
}
const commitHash =
process.env.RAILWAY_GIT_COMMIT_SHA ||
process.env.VERCEL_GIT_COMMIT_SHA ||
process.env.GITHUB_SHA ||
process.env.COMMIT_SHA ||
process.env.SOURCE_COMMIT ||
null
const committedAt =
process.env.BUILD_DATE ||
process.env.RENDER_GIT_COMMIT_DATE ||
process.env.VERCEL_GIT_COMMIT_DATE ||
new Date().toISOString()
return [{
hash: commitHash || `version-${version}`,
shortHash: commitHash ? commitHash.slice(0, 7) : `v${version}`,
subject: `Bereitgestellte Version ${version}`,
authorName: "Deployment",
committedAt
}]
}
export default async function functionRoutes(server: FastifyInstance) {
const streamToBuffer = async (stream: any): Promise<Buffer> =>
new Promise((resolve, reject) => {
@@ -201,7 +238,11 @@ export default async function functionRoutes(server: FastifyInstance) {
const gitRoot = resolveGitRoot()
if (!gitRoot) {
return reply.code(500).send({ error: 'Git repository not found' })
return reply.send({
repositoryRoot: null,
source: 'deployment',
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
})
}
try {
@@ -232,11 +273,16 @@ export default async function functionRoutes(server: FastifyInstance) {
return reply.send({
repositoryRoot: gitRoot,
source: 'git',
entries
})
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: 'Failed to load changelog' })
return reply.send({
repositoryRoot: gitRoot,
source: 'deployment',
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
})
}
})
@@ -261,6 +307,21 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
})
server.get('/functions/liquidity-forecast', async (req, reply) => {
const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
const ignoredKeys = String(ignoredRecurringKeys || "")
.split(",")
.map((key) => key.trim())
.filter(Boolean)
try {
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
}
})
server.post('/functions/services/backfillfiletext', async (req, reply) => {
const tenantId = req.user.tenant_id

View File

@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
const columnMap: Record<string, any> = {
customers: historyitems.customer,
contracts: historyitems.contract,
members: historyitems.customer,
vendors: historyitems.vendor,
projects: historyitems.project,
@@ -26,10 +27,12 @@ const columnMap: Record<string, any> = {
customerspaces: historyitems.customerspace,
customerinventoryitems: historyitems.customerinventoryitem,
memberrelations: historyitems.memberrelation,
outgoingsepamandates: historyitems.outgoingsepamandate,
};
const insertFieldMap: Record<string, string> = {
customers: "customer",
contracts: "contract",
members: "customer",
vendors: "vendor",
projects: "project",
@@ -51,6 +54,7 @@ const insertFieldMap: Record<string, string> = {
customerspaces: "customerspace",
customerinventoryitems: "customerinventoryitem",
memberrelations: "memberrelation",
outgoingsepamandates: "outgoingsepamandate",
}
const parseId = (value: string) => {

View File

@@ -8,6 +8,8 @@ import {
} from "../../../db/schema"
import {and, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
export default async function tenantRoutesInternal(server: FastifyInstance) {
@@ -53,7 +55,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profiles = await server.db
const profileRows = await server.db
.select()
.from(authProfiles)
.where(
@@ -61,6 +63,8 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds)
))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id)
@@ -91,12 +95,13 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const tenantId = req.params.id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data = await server.db
const profileRows = await server.db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId))
return data
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
return await enrichProfilesWithTeams(server, profilesWithBranches)
} catch (err) {
console.error("/tenant/profiles ERROR:", err)

View File

@@ -11,7 +11,7 @@ import {
desc
} from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod";
@@ -102,7 +102,7 @@ export default async function staffTimeRoutesInternal(server: FastifyInstance) {
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
].sort(compareTimeEvents);
// SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents);

299
backend/src/routes/mcp.ts Normal file
View File

@@ -0,0 +1,299 @@
import { FastifyInstance } from "fastify"
import { and, desc, eq } from "drizzle-orm"
import { createHash, randomBytes } from "node:crypto"
import { m2mApiKeys } from "../../db/schema"
import { assertToolPermission, createMcpContext } from "../mcp/authz"
import { mcpToolMap, mcpTools } from "../mcp/registry"
import { asToolError, asToolResult } from "../mcp/result"
import { JsonRpcRequest } from "../mcp/types"
const SUPPORTED_PROTOCOL_VERSIONS = [
"2025-11-25",
"2025-06-18",
"2025-03-26",
"2024-11-05",
]
function jsonRpcResult(id: JsonRpcRequest["id"], result: unknown) {
return {
jsonrpc: "2.0",
id,
result,
}
}
function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: string) {
return {
jsonrpc: "2.0",
id: id ?? null,
error: {
code,
message,
},
}
}
function selectProtocolVersion(clientVersion?: string) {
if (clientVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) {
return clientVersion
}
return SUPPORTED_PROTOCOL_VERSIONS[0]
}
const hashApiKey = (apiKey: string) =>
createHash("sha256").update(apiKey, "utf8").digest("hex")
const createMcpToken = () => `fedeo_mcp_${randomBytes(32).toString("base64url")}`
const requireMcpTokenManagementPermission = (req: any) => {
if (req.mcpTokenAuth) {
throw Object.assign(new Error("MCP Tokens können nur mit einer Benutzersitzung verwaltet werden"), { statusCode: 403 })
}
if (req.user?.is_admin) return
if (typeof req.hasPermission === "function" && req.hasPermission("mcp.tokens.write")) return
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
}
export default async function mcpRoutes(server: FastifyInstance) {
server.get("/mcp/tokens", async (req, reply) => {
try {
requireMcpTokenManagementPermission(req)
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const rows = await server.db
.select({
id: m2mApiKeys.id,
createdAt: m2mApiKeys.createdAt,
updatedAt: m2mApiKeys.updatedAt,
name: m2mApiKeys.name,
keyPrefix: m2mApiKeys.keyPrefix,
active: m2mApiKeys.active,
lastUsedAt: m2mApiKeys.lastUsedAt,
expiresAt: m2mApiKeys.expiresAt,
userId: m2mApiKeys.userId,
createdBy: m2mApiKeys.createdBy,
})
.from(m2mApiKeys)
.where(and(
eq(m2mApiKeys.tenantId, req.user.tenant_id)
))
.orderBy(desc(m2mApiKeys.createdAt))
return { rows: rows.filter((row) => row.keyPrefix.startsWith("fedeo_mcp_")) }
} catch (error) {
const statusCode = (error as any)?.statusCode || 500
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
}
})
server.post("/mcp/tokens", async (req, reply) => {
try {
requireMcpTokenManagementPermission(req)
if (!req.user?.tenant_id || !req.user?.user_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const body = req.body as {
name?: string
expiresAt?: string | null
}
const token = createMcpToken()
const expiresAt = body?.expiresAt ? new Date(body.expiresAt) : null
if (expiresAt && Number.isNaN(expiresAt.getTime())) {
return reply.code(400).send({ error: "expiresAt must be a valid date" })
}
const [created] = await server.db
.insert(m2mApiKeys)
.values({
tenantId: req.user.tenant_id,
userId: req.user.user_id,
createdBy: req.user.user_id,
name: body?.name?.trim() || "FEDEO MCP Token",
keyPrefix: token.slice(0, 20),
keyHash: hashApiKey(token),
active: true,
expiresAt,
})
.returning({
id: m2mApiKeys.id,
createdAt: m2mApiKeys.createdAt,
name: m2mApiKeys.name,
keyPrefix: m2mApiKeys.keyPrefix,
active: m2mApiKeys.active,
expiresAt: m2mApiKeys.expiresAt,
})
return {
token,
tokenType: "Bearer",
note: "Token wird nur einmal angezeigt. Bitte direkt in Codex oder einem Secret Store hinterlegen.",
record: created,
}
} catch (error) {
const statusCode = (error as any)?.statusCode || 500
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
}
})
server.delete("/mcp/tokens/:id", async (req, reply) => {
try {
requireMcpTokenManagementPermission(req)
if (!req.user?.tenant_id || !req.user?.user_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const [existing] = await server.db
.select({
id: m2mApiKeys.id,
keyPrefix: m2mApiKeys.keyPrefix,
})
.from(m2mApiKeys)
.where(and(
eq(m2mApiKeys.id, id),
eq(m2mApiKeys.tenantId, req.user.tenant_id)
))
.limit(1)
if (!existing || !existing.keyPrefix.startsWith("fedeo_mcp_")) {
return reply.code(404).send({ error: "MCP token not found" })
}
const [updated] = await server.db
.update(m2mApiKeys)
.set({
active: false,
updatedAt: new Date(),
})
.where(and(
eq(m2mApiKeys.id, id),
eq(m2mApiKeys.tenantId, req.user.tenant_id)
))
.returning({
id: m2mApiKeys.id,
name: m2mApiKeys.name,
keyPrefix: m2mApiKeys.keyPrefix,
active: m2mApiKeys.active,
})
return { token: updated }
} catch (error) {
const statusCode = (error as any)?.statusCode || 500
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
}
})
server.post("/mcp", async (req, reply) => {
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
const requests = Array.isArray(body) ? body : [body]
const responses = []
for (const request of requests) {
const id = request?.id
if (!request || request.jsonrpc !== "2.0" || !request.method) {
responses.push(jsonRpcError(id, -32600, "Invalid JSON-RPC request"))
continue
}
if (request.method === "notifications/initialized") {
continue
}
if (request.method === "initialize") {
const clientVersion = request.params?.protocolVersion
responses.push(jsonRpcResult(id, {
protocolVersion: selectProtocolVersion(clientVersion),
capabilities: {
tools: {
listChanged: false,
},
},
serverInfo: {
name: "fedeo-mcp",
version: "1.0.0",
},
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.",
}))
continue
}
if (request.method === "ping") {
responses.push(jsonRpcResult(id, {}))
continue
}
if (request.method === "tools/list") {
responses.push(jsonRpcResult(id, {
tools: mcpTools.map((tool) => ({
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
annotations: {
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
},
})),
}))
continue
}
if (request.method === "tools/call") {
const toolName = request.params?.name
const tool = typeof toolName === "string" ? mcpToolMap.get(toolName) : null
if (!tool) {
responses.push(jsonRpcError(id, -32602, `Unknown tool: ${toolName}`))
continue
}
try {
const context = await createMcpContext(server, req)
assertToolPermission(context, tool)
const result = await tool.handler(context, request.params?.arguments || {})
responses.push(jsonRpcResult(id, asToolResult(result)))
} catch (error) {
const statusCode = (error as any)?.statusCode
if (statusCode === 401 || statusCode === 403) {
responses.push(jsonRpcError(id, statusCode === 401 ? -32001 : -32003, error instanceof Error ? error.message : "Forbidden"))
} else {
responses.push(jsonRpcResult(id, asToolError(error)))
}
}
continue
}
responses.push(jsonRpcError(id, -32601, `Method not found: ${request.method}`))
}
if (responses.length === 0) {
return reply.code(204).send()
}
return Array.isArray(body) ? responses : responses[0]
})
server.get("/mcp", async (_req, reply) => {
return reply.send({
name: "fedeo-mcp",
transport: "http-json-rpc",
endpoint: "/api/mcp",
tools: mcpTools.map((tool) => tool.name),
})
})
}

View File

@@ -1,31 +1,92 @@
// routes/notifications.routes.ts
import { FastifyInstance } from 'fastify';
import { NotificationService, UserDirectory } from '../modules/notification.service';
import { eq } from "drizzle-orm";
import { authUsers } from "../../db/schema";
import { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { authUsers } from "../../db/schema"
import { NotificationService, UserDirectory } from "../modules/notification.service"
// Beispiel: E-Mail aus eigener User-Tabelle laden
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
const rows = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
const data = rows[0]
if (!data) return null;
return { email: data.email };
};
if (!data) return null
return { email: data.email }
}
const requireTenant = (tenantId: number | null) => {
if (!tenantId) throw new Error("Kein aktiver Mandant")
return tenantId
}
export default async function notificationsRoutes(server: FastifyInstance) {
const svc = new NotificationService(server, getUserDirectory);
const svc = new NotificationService(server, getUserDirectory)
server.post('/notifications/trigger', async (req, reply) => {
try {
const res = await svc.trigger(req.body as any);
reply.send(res);
} catch (err: any) {
server.log.error(err);
reply.code(500).send({ error: err.message });
server.get("/notifications", async (req) => {
const limit = Number((req.query as { limit?: string })?.limit || 50)
return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit)
})
server.post("/notifications/:id/read", async (req, reply) => {
const params = req.params as { id: string }
const item = await svc.markRead(requireTenant(req.user.tenant_id), req.user.user_id, params.id)
if (!item) return reply.code(404).send({ error: "Benachrichtigung nicht gefunden" })
return item
})
server.get("/notifications/push/config", async () => {
return svc.getPublicPushConfig()
})
server.post("/notifications/push/subscribe", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const userAgent = req.headers["user-agent"]
const subscription = await svc.registerPushSubscription(
tenantId,
req.user.user_id,
req.body as any,
Array.isArray(userAgent) ? userAgent.join(" ") : userAgent
)
return {
success: true,
id: subscription?.id,
}
});
})
server.delete("/notifications/push/subscribe", async (req) => {
const body = (req.body || {}) as { endpoint?: string }
if (!body.endpoint) throw new Error("endpoint fehlt")
return await svc.disablePushSubscription(requireTenant(req.user.tenant_id), req.user.user_id, body.endpoint)
})
server.post("/notifications/test-push", async (req) => {
return await svc.trigger({
tenantId: requireTenant(req.user.tenant_id),
userId: req.user.user_id,
eventType: "system.test_push",
title: "FEDEO Desktop Push ist aktiv",
message: "Diese Testbenachrichtigung wurde von FEDEO selbst zugestellt.",
payload: {
link: "/",
icon: "/favicon.ico",
},
channels: ["inapp", "push"],
})
})
server.post("/notifications/trigger", async (req, reply) => {
try {
const body = req.body as any
const tenantId = body.tenantId || req.user.tenant_id
const res = await svc.trigger({
...body,
tenantId: requireTenant(tenantId),
})
reply.send(res)
} catch (err: any) {
server.log.error(err)
reply.code(500).send({ error: err.message })
}
})
}

View File

@@ -0,0 +1,230 @@
import { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import { authProfiles, contracts, contracttypes } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
async function getPortalContract(server: FastifyInstance, req: any, contractId: number) {
const portalCustomerId = await getPortalCustomerId(server, req)
if (!portalCustomerId) return null
const [contract] = await server.db
.select({
id: contracts.id,
name: contracts.name,
tenant: contracts.tenant,
customer: contracts.customer,
contracttype: contracts.contracttype,
allowedContracttypes: contracts.allowedContracttypes,
archived: contracts.archived,
})
.from(contracts)
.where(and(
eq(contracts.id, contractId),
eq(contracts.tenant, req.user?.tenant_id),
eq(contracts.customer, portalCustomerId),
eq(contracts.archived, false)
))
.limit(1)
return contract || null
}
function normalizeMessage(message: unknown) {
if (typeof message !== "string") return ""
return message.trim()
}
function appendMessage(text: string, message: string) {
return message ? `${text} Nachricht: ${message}` : text
}
function formatDateForHistory(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
}).format(date)
}
export default async function portalContractRoutes(server: FastifyInstance) {
server.post<{
Params: { id: string }
Body: { contracttype?: number | string; message?: string }
}>("/portal/contracts/:id/change-request", {
schema: {
tags: ["Portal"],
summary: "Request contract type change from customer portal",
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
},
},
body: {
type: "object",
required: ["contracttype"],
properties: {
contracttype: { anyOf: [{ type: "number" }, { type: "string" }] },
message: { type: "string", nullable: true },
},
},
},
}, async (req, reply) => {
const contractId = Number(req.params.id)
const requestedContracttypeId = Number(req.body.contracttype)
if (!Number.isInteger(contractId) || !Number.isInteger(requestedContracttypeId)) {
return reply.code(400).send({ error: "Ungültige Anfrage" })
}
const contract = await getPortalContract(server, req, contractId)
if (!contract) {
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
}
const [requestedContracttype] = await server.db
.select({
id: contracttypes.id,
name: contracttypes.name,
})
.from(contracttypes)
.where(and(
eq(contracttypes.id, requestedContracttypeId),
eq(contracttypes.tenant, req.user?.tenant_id),
eq(contracttypes.archived, false)
))
.limit(1)
if (!requestedContracttype) {
return reply.code(400).send({ error: "Ungültiger Vertragstyp" })
}
const allowedContracttypes = Array.isArray(contract.allowedContracttypes)
? contract.allowedContracttypes.map((id) => Number(id)).filter((id) => Number.isInteger(id))
: []
if (!allowedContracttypes.includes(requestedContracttype.id)) {
return reply.code(400).send({ error: "Dieser Vertragstyp steht für diesen Vertrag nicht zur Auswahl" })
}
const [currentContracttype] = contract.contracttype
? await server.db
.select({
id: contracttypes.id,
name: contracttypes.name,
})
.from(contracttypes)
.where(and(
eq(contracttypes.id, contract.contracttype),
eq(contracttypes.tenant, req.user?.tenant_id)
))
.limit(1)
: []
const message = normalizeMessage(req.body.message)
const oldName = currentContracttype?.name || "Ohne Vertragstyp"
const newName = requestedContracttype.name
const text = appendMessage(
`Kundenportal: Änderung des Vertragstyps von "${oldName}" auf "${newName}" angefragt.`,
message
)
await insertHistoryItem(server, {
tenant_id: req.user?.tenant_id,
created_by: req.user?.user_id || null,
entity: "contracts",
entityId: contract.id,
action: "unchanged",
oldVal: { contracttype: contract.contracttype, name: oldName },
newVal: { contracttype: requestedContracttype.id, name: newName },
text,
})
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
})
server.post<{
Params: { id: string }
Body: { requestedEndDate?: string; message?: string }
}>("/portal/contracts/:id/cancellation-request", {
schema: {
tags: ["Portal"],
summary: "Request contract cancellation from customer portal",
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
},
},
body: {
type: "object",
required: ["requestedEndDate"],
properties: {
requestedEndDate: { type: "string" },
message: { type: "string", nullable: true },
},
},
},
}, async (req, reply) => {
const contractId = Number(req.params.id)
const requestedEndDate = typeof req.body.requestedEndDate === "string"
? req.body.requestedEndDate.trim()
: ""
if (!Number.isInteger(contractId) || !requestedEndDate) {
return reply.code(400).send({ error: "Ungültige Anfrage" })
}
const parsedDate = new Date(requestedEndDate)
if (Number.isNaN(parsedDate.getTime())) {
return reply.code(400).send({ error: "Ungültiges Kündigungsdatum" })
}
const contract = await getPortalContract(server, req, contractId)
if (!contract) {
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
}
const message = normalizeMessage(req.body.message)
const text = appendMessage(
`Kundenportal: Kündigung zum ${formatDateForHistory(requestedEndDate)} angefragt.`,
message
)
await insertHistoryItem(server, {
tenant_id: req.user?.tenant_id,
created_by: req.user?.user_id || null,
entity: "contracts",
entityId: contract.id,
action: "unchanged",
newVal: { requestedEndDate },
text,
})
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
})
}

View File

@@ -4,6 +4,20 @@ import { eq, and } from "drizzle-orm";
import {
authProfiles,
} from "../../db/schema";
import {
loadProfileWithBranches,
resolveTenantBranchIds,
syncProfileBranches,
} from "../utils/profileBranches";
import {
enrichProfilesWithTeams,
resolveTenantTeamIds,
syncProfileTeams,
} from "../utils/profileTeams";
import {
enrichProfileWithCalendarSubscription,
generateProfileCalendarSubscriptionToken,
} from "../utils/calendarSubscription";
export default async function authProfilesRoutes(server: FastifyInstance) {
@@ -19,22 +33,16 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: "No tenant selected" });
}
const rows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId)
)
)
.limit(1);
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [null]
if (!rows.length) {
if (!profile) {
return reply.code(404).send({ error: "User not found or not in tenant" });
}
return rows[0];
return enrichProfileWithCalendarSubscription(profile);
} catch (error) {
console.error("GET /profiles/:id ERROR:", error);
@@ -46,9 +54,11 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
const cleaned: any = { ...body }
// ❌ Systemfelder entfernen
const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name"
const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name",
"branch", "calendar_subscription_token",
"calendar_subscription_path", "calendar_subscription_url"
]
forbidden.forEach(f => delete cleaned[f])
@@ -89,8 +99,32 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
// Clean + Normalize
body = sanitizeProfileUpdate(body)
const { primaryBranchId, branchIds } = await resolveTenantBranchIds(
server,
tenantId,
[
...(Array.isArray(body.branch_ids) ? body.branch_ids : []),
...(Array.isArray(body.branches) ? body.branches : []),
],
body.branch_id ?? body.branch?.id ?? null
)
const teamIds = await resolveTenantTeamIds(
server,
tenantId,
[
...(Array.isArray(body.team_ids) ? body.team_ids : []),
...(Array.isArray(body.teams) ? body.teams : []),
],
)
delete body.branch_ids
delete body.branches
delete body.team_ids
delete body.teams
const updateData = {
...body,
branch_id: primaryBranchId,
updatedAt: new Date(),
updatedBy: userId
}
@@ -110,10 +144,50 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "User not found or not in tenant" })
}
return updated[0]
await syncProfileBranches(server, id, branchIds, userId)
await syncProfileTeams(server, id, teamIds, userId)
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [null]
return enrichProfileWithCalendarSubscription(profile || updated[0])
} catch (err) {
console.error("PUT /profiles/:id ERROR:", err)
if (err instanceof Error && ["INVALID_BRANCH_SELECTION", "INVALID_PRIMARY_BRANCH"].includes(err.message)) {
return reply.code(400).send({ error: "Ungültige Niederlassungsauswahl" })
}
if (err instanceof Error && err.message === "INVALID_TEAM_SELECTION") {
return reply.code(400).send({ error: "Ungültige Teamauswahl" })
}
return reply.code(500).send({ error: "Internal Server Error" })
}
})
server.post("/profiles/:id/calendar-subscription-token", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const updatedProfile = await generateProfileCalendarSubscriptionToken(server, id, tenantId)
if (!updatedProfile) {
return reply.code(404).send({ error: "User not found or not in tenant" })
}
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [updatedProfile]
return enrichProfileWithCalendarSubscription(profile)
} catch (err) {
console.error("POST /profiles/:id/calendar-subscription-token ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})

View File

@@ -1,8 +1,32 @@
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import { publicLinkService } from '../../modules/publiclinks.service';
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
import { buildProfileCalendarSubscriptionFeed, loadProfileByCalendarSubscriptionToken } from '../../utils/calendarSubscription';
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
server.get("/api/public/calendar/subscriptions/:token.ics", async (req, reply) => {
const { token } = req.params as { token: string }
try {
const profile = await loadProfileByCalendarSubscriptionToken(server, token)
if (!profile || !profile.active) {
return reply.code(404).send({ error: "Kalender-Abo nicht gefunden" })
}
const icsFeed = await buildProfileCalendarSubscriptionFeed(server, profile)
reply.header("Content-Type", "text/calendar; charset=utf-8")
reply.header("Content-Disposition", `inline; filename="fedeo-${profile.id}.ics"`)
reply.header("Cache-Control", "private, max-age=300")
return reply.send(icsFeed)
} catch (error: any) {
server.log.error(error)
return reply.code(500).send({ error: "Interner Server Fehler" })
}
})
server.get("/workflows/context/:token", async (req, reply) => {
const { token } = req.params as { token: string };
const pin = req.headers['x-public-pin'] as string | undefined;
@@ -49,4 +73,4 @@ export default async function publiclinksNonAuthenticatedRoutes(server: FastifyI
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
}
});
}
}

View File

@@ -11,6 +11,7 @@ import {
sql,
} from "drizzle-orm"
import { authProfiles, costcentres, customers, entitybankaccounts } from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
@@ -18,6 +19,12 @@ import { diffObjects } from "../../utils/diff";
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
import { decrypt, encrypt } from "../../utils/crypt";
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
const OUTGOING_SEPA_MANDATE_STATUSES = new Set(["Entwurf", "Aktiv", "Widerrufen", "Abgelaufen"])
const OUTGOING_SEPA_MANDATE_TYPES = new Set(["CORE", "B2B"])
const OUTGOING_SEPA_SEQUENCE_TYPES = new Set(["RCUR", "OOFF", "FRST", "FNAL"])
// -------------------------------------------------------------
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
// -------------------------------------------------------------
@@ -130,12 +137,92 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
return whereCond
}
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
if (!portalCustomerId) return whereCond
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
return null
}
if (resource === "customers") {
return and(whereCond, eq(table.id, portalCustomerId))
}
if (resource === "contracts") {
return and(whereCond, eq(table.customer, portalCustomerId))
}
if (resource === "createddocuments") {
return and(
whereCond,
eq(table.customer, portalCustomerId),
eq(table.availableInPortal, true),
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
)
}
return whereCond
}
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
return {
name: payload.name,
firstname: payload.firstname,
lastname: payload.lastname,
salutation: payload.salutation,
title: payload.title,
nameAddition: payload.nameAddition,
infoData: nextInfoData,
}
}
function getTenantColumn(resource: string, table: any) {
const config = resourceConfig[resource]
const tenantKey = config?.tenantKey || "tenant"
return table[tenantKey]
}
function getRelationConfig(relation: string) {
const candidateKeys = [
relation,
`${relation}s`,
]
if (relation.endsWith("y")) {
candidateKeys.push(`${relation.slice(0, -1)}ies`)
}
if (/(s|x|z|ch|sh)$/.test(relation)) {
candidateKeys.push(`${relation}es`)
}
for (const key of candidateKeys) {
if (resourceConfig[key]) return resourceConfig[key]
}
return null
}
function isDateLikeField(key: string) {
if (key === "deliveryDateType") return false
if (key.includes("_at") || key.endsWith("At")) return true
@@ -143,6 +230,53 @@ function isDateLikeField(key: string) {
return /(^|_|-)date($|_|-)/i.test(key)
}
function isDateValue(value: any) {
if (value instanceof Date) return !Number.isNaN(value.getTime())
if (typeof value !== "string") return false
const normalized = value.trim()
if (/^\d+$/.test(normalized)) return false
return !Number.isNaN(new Date(normalized).getTime())
}
function normalizeCreatedDocumentPayload(payload: Record<string, any>) {
const numberRelationFields = [
"customer",
"contact",
"project",
"createddocument",
"letterhead",
"plant",
"contract",
"outgoingsepamandate",
]
for (const field of numberRelationFields) {
const value = payload[field]
if (value instanceof Date || (typeof value === "string" && isDateValue(value))) {
payload[field] = null
}
}
const serialexecution = payload.serialexecution
if (serialexecution === undefined || serialexecution === null || serialexecution === "") return payload
if (isDateValue(serialexecution)) {
payload.serialexecution = null
return payload
}
if (typeof serialexecution === "string") {
const normalized = serialexecution.trim()
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)
if (!isUuid && isDateValue(normalized)) {
payload.serialexecution = null
return payload
}
}
return payload
}
function normalizeMemberPayload(payload: Record<string, any>) {
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
const normalized = {
@@ -176,6 +310,59 @@ function validateMemberPayload(payload: Record<string, any>) {
return null
}
async function validateCostCentreParent(
server: FastifyInstance,
tenantId: number,
costCentreId: string | null,
parentCostcentreId: string | null
) {
if (!parentCostcentreId) {
return null
}
const hierarchyRows = await server.db
.select({
id: costcentres.id,
parentCostcentre: costcentres.parentCostcentre,
})
.from(costcentres)
.where(eq(costcentres.tenant, tenantId))
const hierarchyMap = new Map(
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
)
if (!hierarchyMap.has(parentCostcentreId)) {
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
}
if (costCentreId && parentCostcentreId === costCentreId) {
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
}
if (!costCentreId) {
return null
}
let currentParentId: string | null = parentCostcentreId
const visited = new Set<string>()
while (currentParentId) {
if (currentParentId === costCentreId) {
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
}
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
currentParentId = hierarchyMap.get(currentParentId) || null
}
return null
}
function maskIban(iban: string) {
if (!iban) return ""
const cleaned = iban.replace(/\s+/g, "")
@@ -184,16 +371,28 @@ function maskIban(iban: string) {
}
function decryptEntityBankAccount(row: Record<string, any>) {
const iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
const bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
const bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null
let iban = null
let bic = null
let bankName = null
let decryptError = null
try {
iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null
} catch (err: any) {
decryptError = err?.message || "Bankverbindung konnte nicht entschlüsselt werden."
}
return {
...row,
iban,
bic,
bankName,
displayLabel: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(),
decryptError,
displayLabel: decryptError
? `Bankverbindung nicht lesbar${row.description ? ` (${row.description})` : ""}`
: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(),
}
}
@@ -228,6 +427,65 @@ function prepareEntityBankAccountPayload(payload: Record<string, any>, requireAl
return { data: result }
}
async function validateOutgoingSepaMandatePayload(
server: FastifyInstance,
tenantId: number,
payload: Record<string, any>,
existing: Record<string, any> | null = null
) {
const customerId = Number(payload.customer ?? existing?.customer)
const bankaccountId = Number(payload.bankaccount ?? existing?.bankaccount)
if (!customerId || !bankaccountId) {
return "Kunde und Bankverbindung sind Pflichtfelder."
}
const status = payload.status ?? existing?.status ?? "Entwurf"
if (!OUTGOING_SEPA_MANDATE_STATUSES.has(status)) {
return "Ungültiger Mandatsstatus."
}
const mandateType = payload.mandateType ?? existing?.mandateType ?? "CORE"
if (!OUTGOING_SEPA_MANDATE_TYPES.has(mandateType)) {
return "Ungültiger Mandatstyp."
}
const sequenceType = payload.sequenceType ?? existing?.sequenceType ?? "RCUR"
if (!OUTGOING_SEPA_SEQUENCE_TYPES.has(sequenceType)) {
return "Ungültige Mandatssequenz."
}
const [customer] = await server.db
.select()
.from(customers)
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
.limit(1)
if (!customer) {
return "Kunde nicht gefunden."
}
const [bankaccount] = await server.db
.select()
.from(entitybankaccounts)
.where(and(eq(entitybankaccounts.id, bankaccountId), eq(entitybankaccounts.tenant, tenantId)))
.limit(1)
if (!bankaccount) {
return "Bankverbindung nicht gefunden."
}
const assignedBankAccountIds = Array.isArray((customer.infoData as any)?.bankAccountIds)
? (customer.infoData as any).bankAccountIds.map((id: any) => Number(id))
: []
if (!assignedBankAccountIds.includes(bankaccountId)) {
return "Die Bankverbindung ist dem ausgewählten Kunden nicht zugeordnet."
}
return null
}
export default async function resourceRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
@@ -250,18 +508,23 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!config) {
return reply.code(404).send({ error: "Unknown resource" })
}
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table
const tenantColumn = getTenantColumn(resource, table)
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
let q = server.db.select().from(table).$dynamic()
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]
const relConfig = getRelationConfig(rel)
if (relConfig) {
const relTable = relConfig.table
@@ -307,7 +570,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
})
for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
const relConf = getRelationConfig(rel)
if (!relConf) continue
const relTab = relConf.table
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
@@ -358,6 +622,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!config) {
return reply.code(404).send({ error: "Unknown resource" });
}
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table;
const { queryConfig } = req;
@@ -367,6 +635,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table);
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
const parsedFilters: Array<{ key: string; value: any }> = []
@@ -376,7 +645,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
const relConfig = getRelationConfig(rel)
if (relConfig) {
const relTable = relConfig.table;
@@ -457,7 +726,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
if (config.mtoLoad) {
config.mtoLoad.forEach(rel => {
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
const relConfig = getRelationConfig(rel)
if (!relConfig) return;
const relTable = relConfig.table;
if (relTable !== table) {
@@ -467,6 +736,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim())
@@ -496,7 +766,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
});
for await (const rel of config.mtoLoad) {
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
const relConf = getRelationConfig(rel)
if (!relConf) continue
const relTab = relConf.table;
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
@@ -547,10 +818,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const projRows = await server.db
.select()
@@ -567,7 +843,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad) {
if (data[relation]) {
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
const relConf = getRelationConfig(relation)
if (!relConf) continue
const relTable = relConf.table
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
data[relation] = relData[0] || null
@@ -600,6 +877,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId) {
return reply.code(403).send({ error: "Forbidden" })
}
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
@@ -623,6 +904,30 @@ export default async function resourceRoutes(server: FastifyInstance) {
createData = prepared.data!
}
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
req.user.tenant_id,
null,
createData.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "outgoingsepamandates") {
const validationError = await validateOutgoingSepaMandatePayload(server, req.user.tenant_id, createData)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "createddocuments") {
createData = normalizeCreatedDocumentPayload(createData)
}
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const numberRangeResource = resource === "members" ? "customers" : resource
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
@@ -631,11 +936,31 @@ export default async function resourceRoutes(server: FastifyInstance) {
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
Object.keys(createData).forEach((key) => {
if (key.toLowerCase().includes("date") && key !== "deliveryDateType") createData[key] = normalizeDate(createData[key])
const value = createData[key]
const shouldNormalize =
isDateLikeField(key) &&
value !== null &&
value !== undefined &&
(typeof value === "string" || typeof value === "number" || value instanceof Date)
if (shouldNormalize) {
createData[key] = normalizeDate(value)
}
})
const [created] = await server.db.insert(table).values(createData).returning()
if (resource === "outgoingsepamandates" && created?.defaultMandate) {
await server.db
.update(table)
.set({ defaultMandate: false })
.where(and(
eq(table.tenant, req.user.tenant_id),
eq(table.customer, created.customer),
sql`${table.id} <> ${created.id}`
))
}
if (["products", "services", "hourrates"].includes(resource)) {
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
}
@@ -679,8 +1004,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
const portalCustomerId = await getPortalCustomerId(server, req)
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
if (portalCustomerId && resource !== "customers") {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
@@ -688,13 +1017,33 @@ export default async function resourceRoutes(server: FastifyInstance) {
const [oldRecord] = await server.db
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
.limit(1)
if (!oldRecord) {
return reply.code(404).send({ error: "Resource not found" })
}
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
//@ts-ignore
delete data.updatedBy; delete data.updatedAt;
if (resource === "filetags") {
delete data.isSystemUsed
if (oldRecord.isSystemUsed && data.archived === true) {
return reply.code(400).send({ error: "System-Dateitypen können nicht archiviert werden" })
}
}
if (portalCustomerId) {
data = {
...sanitizePortalCustomerUpdate(data),
updated_at: data.updated_at,
updated_by: data.updated_by,
}
}
if (resource === "members") {
data = normalizeMemberPayload(data)
const validationError = validateMemberPayload(data)
@@ -713,6 +1062,32 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
tenantId,
oldRecord.id,
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
? data.parentCostcentre || null
: oldRecord.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "outgoingsepamandates") {
const validationError = await validateOutgoingSepaMandatePayload(server, tenantId, data, oldRecord)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (resource === "createddocuments") {
data = normalizeCreatedDocumentPayload(data)
}
Object.keys(data).forEach((key) => {
const value = data[key]
const shouldNormalize =
@@ -730,6 +1105,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
if (resource === "outgoingsepamandates" && updated?.defaultMandate) {
await server.db
.update(table)
.set({ defaultMandate: false })
.where(and(
eq(table.tenant, tenantId),
eq(table.customer, updated.customer),
sql`${table.id} <> ${updated.id}`
))
}
if (["products", "services", "hourrates"].includes(resource)) {
await recalculateServicePricesForTenant(server, tenantId, userId);
}

View File

@@ -4,6 +4,7 @@ import { sortData } from "../utils/sort"
// Schema imports
import { accounts, units, countrys, tenants } from "../../db/schema"
import { defaultCountries } from "../utils/countries"
const TABLE_MAP: Record<string, any> = {
accounts,
@@ -96,6 +97,24 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
const data = await query
if (resource === "countrys") {
const countryMap = new Map<string, any>()
for (const country of defaultCountries) {
countryMap.set(country.toLocaleLowerCase("de"), { id: country, name: country })
}
for (const country of data) {
countryMap.set(country.name.toLocaleLowerCase("de"), country)
}
return sortData(
Array.from(countryMap.values()),
sort || "name",
sort ? ascQuery === "true" : true
)
}
// Falls sort clientseitig wie früher notwendig ist:
const sorted = sortData(
data,

View File

@@ -11,7 +11,7 @@ import {
desc
} from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod";
@@ -354,7 +354,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
].sort(compareTimeEvents);
// SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents);
@@ -424,7 +424,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
].sort(compareTimeEvents);
// SCHRITT 4: Ableiten und Anreichern
const derivedSpans = deriveTimeSpans(combinedEvents);
@@ -453,4 +453,4 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
}
});
}
}

View File

@@ -12,6 +12,8 @@ import {
} from "../../db/schema"
import {and, desc, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams"
export default async function tenantRoutes(server: FastifyInstance) {
@@ -123,7 +125,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profiles = await server.db
const profileRows = await server.db
.select()
.from(authProfiles)
.where(
@@ -131,6 +133,8 @@ export default async function tenantRoutes(server: FastifyInstance) {
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds)
))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id)
@@ -160,11 +164,13 @@ export default async function tenantRoutes(server: FastifyInstance) {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data = await server.db
const profileRows = await server.db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
return { data }
} catch (err) {

View File

@@ -0,0 +1,193 @@
import crypto from "crypto"
import { and, asc, eq, sql } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfiles, events } from "../../db/schema"
import { secrets } from "./secrets"
const CALENDAR_FEED_PREFIX = "/api/public/calendar/subscriptions"
function escapeIcsText(value: string) {
return value
.replace(/\\/g, "\\\\")
.replace(/\r?\n/g, "\\n")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
}
function foldIcsLine(line: string) {
const maxLength = 73
if (line.length <= maxLength) return line
const parts: string[] = []
for (let index = 0; index < line.length; index += maxLength) {
parts.push(index === 0 ? line.slice(index, index + maxLength) : ` ${line.slice(index, index + maxLength)}`)
}
return parts.join("\r\n")
}
function formatUtcDate(value: string | Date | null | undefined) {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}Z$/, "Z")
}
function buildRecurrenceRule(repeatInterval?: string | null) {
switch (repeatInterval) {
case "Täglich":
return "FREQ=DAILY"
case "Wöchentlich":
return "FREQ=WEEKLY"
case "2-wöchentlich":
return "FREQ=WEEKLY;INTERVAL=2"
case "Monatlich":
return "FREQ=MONTHLY"
case "Jährlich":
return "FREQ=YEARLY"
default:
return null
}
}
function normalizeApiBaseUrl() {
const rawBase = secrets.API_BASE_URL?.trim()
if (!rawBase) return null
return rawBase.replace(/\/+$/, "")
}
export function buildCalendarSubscriptionPath(token: string) {
return `${CALENDAR_FEED_PREFIX}/${token}.ics`
}
export function buildCalendarSubscriptionUrl(token: string) {
const apiBaseUrl = normalizeApiBaseUrl()
const subscriptionPath = buildCalendarSubscriptionPath(token)
if (!apiBaseUrl) return subscriptionPath
if (apiBaseUrl.endsWith("/api")) {
return `${apiBaseUrl}${subscriptionPath.replace(/^\/api/, "")}`
}
return `${apiBaseUrl}${subscriptionPath}`
}
export function enrichProfileWithCalendarSubscription(profile: Record<string, any> | null) {
if (!profile) return profile
return {
...profile,
calendar_subscription_path: profile.calendar_subscription_token
? buildCalendarSubscriptionPath(profile.calendar_subscription_token)
: null,
calendar_subscription_url: profile.calendar_subscription_token
? buildCalendarSubscriptionUrl(profile.calendar_subscription_token)
: null,
}
}
export async function generateProfileCalendarSubscriptionToken(server: FastifyInstance, profileId: string, tenantId: number) {
const token = crypto.randomBytes(24).toString("hex")
const [profile] = await server.db
.update(authProfiles)
.set({
calendar_subscription_token: token,
})
.where(
and(
eq(authProfiles.id, profileId),
eq(authProfiles.tenant_id, tenantId)
)
)
.returning()
return profile || null
}
export async function loadProfileByCalendarSubscriptionToken(server: FastifyInstance, token: string) {
return server.db.query.authProfiles.findFirst({
where: eq(authProfiles.calendar_subscription_token, token)
})
}
export async function buildProfileCalendarSubscriptionFeed(server: FastifyInstance, profile: typeof authProfiles.$inferSelect) {
const assignedEvents = await server.db
.select({
id: events.id,
name: events.name,
startDate: events.startDate,
endDate: events.endDate,
repeatInterval: events.repeatInterval,
notes: events.notes,
link: events.link,
state: events.state,
color: events.color,
createdAt: events.createdAt,
updatedAt: events.updatedAt
})
.from(events)
.where(
and(
eq(events.tenant, profile.tenant_id),
eq(events.archived, false),
sql`${events.profiles} ? ${profile.id}`
)
)
.orderBy(asc(events.startDate))
const nowStamp = formatUtcDate(new Date()) || ""
const calendarLines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//FEDEO//Kalender-Abo//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
foldIcsLine(`X-WR-CALNAME:${escapeIcsText(`FEDEO - ${profile.full_name}`)}`),
foldIcsLine(`X-WR-CALDESC:${escapeIcsText(`Kalender-Abo für ${profile.full_name}`)}`),
]
assignedEvents.forEach((event) => {
const startDate = formatUtcDate(event.startDate)
if (!startDate) return
const endDate = formatUtcDate(event.endDate)
const lastModified = formatUtcDate(event.updatedAt || event.createdAt) || nowStamp
const recurrenceRule = buildRecurrenceRule(event.repeatInterval)
const descriptionParts = [event.notes, event.link].filter(Boolean)
const summary = event.state === "Entwurf" ? `[Entwurf] ${event.name}` : event.name
calendarLines.push("BEGIN:VEVENT")
calendarLines.push(foldIcsLine(`UID:fedeo-event-${event.id}@fedeo.local`))
calendarLines.push(`DTSTAMP:${nowStamp}`)
calendarLines.push(`DTSTART:${startDate}`)
if (endDate) {
calendarLines.push(`DTEND:${endDate}`)
}
calendarLines.push(`LAST-MODIFIED:${lastModified}`)
calendarLines.push(foldIcsLine(`SUMMARY:${escapeIcsText(summary)}`))
calendarLines.push(`STATUS:${event.state === "Entwurf" ? "TENTATIVE" : "CONFIRMED"}`)
if (event.color) {
calendarLines.push(foldIcsLine(`COLOR:${escapeIcsText(event.color)}`))
}
if (descriptionParts.length) {
calendarLines.push(foldIcsLine(`DESCRIPTION:${escapeIcsText(descriptionParts.join("\n\n"))}`))
}
if (recurrenceRule) {
calendarLines.push(`RRULE:${recurrenceRule}`)
}
calendarLines.push("END:VEVENT")
})
calendarLines.push("END:VCALENDAR")
return `${calendarLines.join("\r\n")}\r\n`
}

View File

@@ -0,0 +1,258 @@
const COUNTRY_CODES = [
"AF",
"AX",
"AL",
"DZ",
"AS",
"AD",
"AO",
"AI",
"AQ",
"AG",
"AR",
"AM",
"AW",
"AU",
"AT",
"AZ",
"BS",
"BH",
"BD",
"BB",
"BY",
"BE",
"BZ",
"BJ",
"BM",
"BT",
"BO",
"BQ",
"BA",
"BW",
"BV",
"BR",
"IO",
"BN",
"BG",
"BF",
"BI",
"CV",
"KH",
"CM",
"CA",
"KY",
"CF",
"TD",
"CL",
"CN",
"CX",
"CC",
"CO",
"KM",
"CG",
"CD",
"CK",
"CR",
"CI",
"HR",
"CU",
"CW",
"CY",
"CZ",
"DK",
"DJ",
"DM",
"DO",
"EC",
"EG",
"SV",
"GQ",
"ER",
"EE",
"SZ",
"ET",
"FK",
"FO",
"FJ",
"FI",
"FR",
"GF",
"PF",
"TF",
"GA",
"GM",
"GE",
"DE",
"GH",
"GI",
"GR",
"GL",
"GD",
"GP",
"GU",
"GT",
"GG",
"GN",
"GW",
"GY",
"HT",
"HM",
"VA",
"HN",
"HK",
"HU",
"IS",
"IN",
"ID",
"IR",
"IQ",
"IE",
"IM",
"IL",
"IT",
"JM",
"JP",
"JE",
"JO",
"KZ",
"KE",
"KI",
"KP",
"KR",
"KW",
"KG",
"LA",
"LV",
"LB",
"LS",
"LR",
"LY",
"LI",
"LT",
"LU",
"MO",
"MG",
"MW",
"MY",
"MV",
"ML",
"MT",
"MH",
"MQ",
"MR",
"MU",
"YT",
"MX",
"FM",
"MD",
"MC",
"MN",
"ME",
"MS",
"MA",
"MZ",
"MM",
"NA",
"NR",
"NP",
"NL",
"NC",
"NZ",
"NI",
"NE",
"NG",
"NU",
"NF",
"MK",
"MP",
"NO",
"OM",
"PK",
"PW",
"PS",
"PA",
"PG",
"PY",
"PE",
"PH",
"PN",
"PL",
"PT",
"PR",
"QA",
"RE",
"RO",
"RU",
"RW",
"BL",
"SH",
"KN",
"LC",
"MF",
"PM",
"VC",
"WS",
"SM",
"ST",
"SA",
"SN",
"RS",
"SC",
"SL",
"SG",
"SX",
"SK",
"SI",
"SB",
"SO",
"ZA",
"GS",
"SS",
"ES",
"LK",
"SD",
"SR",
"SJ",
"SE",
"CH",
"SY",
"TW",
"TJ",
"TZ",
"TH",
"TL",
"TG",
"TK",
"TO",
"TT",
"TN",
"TR",
"TM",
"TC",
"TV",
"UG",
"UA",
"AE",
"GB",
"UM",
"US",
"UY",
"UZ",
"VU",
"VE",
"VN",
"VG",
"VI",
"WF",
"EH",
"YE",
"ZM",
"ZW",
] as const
const countryNames = new Intl.DisplayNames(["de"], { type: "region" })
export const defaultCountries = COUNTRY_CODES
.map((code) => countryNames.of(code))
.filter((name): name is string => Boolean(name))
.sort((a, b) => a.localeCompare(b, "de"))

View File

@@ -2,11 +2,18 @@ import crypto from "crypto";
import {secrets} from "./secrets"
const ALGORITHM = "aes-256-gcm";
function getEncryptionKey() {
const key = secrets.ENCRYPTION_KEY || ""
if (!/^[a-f0-9]{64}$/i.test(key)) {
throw new Error("ENCRYPTION_KEY muss ein 64 Zeichen langer Hex-String sein. Beispiel: openssl rand -hex 32")
}
return Buffer.from(key, "hex")
}
export function encrypt(text) {
const ENCRYPTION_KEY = Buffer.from(secrets.ENCRYPTION_KEY, "hex");
const ENCRYPTION_KEY = getEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
@@ -21,7 +28,7 @@ export function encrypt(text) {
}
export function decrypt({ iv, content, tag }) {
const ENCRYPTION_KEY = Buffer.from(secrets.ENCRYPTION_KEY, "hex");
const ENCRYPTION_KEY = getEncryptionKey();
const decipher = crypto.createDecipheriv(
ALGORITHM,
ENCRYPTION_KEY,

View File

@@ -1,6 +1,8 @@
import xmlbuilder from "xmlbuilder";
import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween.js";
import utc from "dayjs/plugin/utc.js";
import timezone from "dayjs/plugin/timezone.js";
import { BlobWriter, Data64URIReader, TextReader, ZipWriter } from "@zip.js/zip.js";
import { FastifyInstance } from "fastify";
import { GetObjectCommand } from "@aws-sdk/client-s3";
@@ -8,7 +10,7 @@ import { s3 } from "../s3";
import { secrets } from "../secrets";
// Drizzle Core Imports
import { eq, and, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
import { eq, and, or, isNull, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
// Tabellen Imports (keine Relations nötig!)
import {
@@ -25,6 +27,8 @@ import {
} from "../../../db/schema";
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(timezone);
// ---------------------------------------------------------
// HELPER FUNCTIONS (Unverändert)
@@ -34,17 +38,28 @@ const getCreatedDocumentTotal = (item: any) => {
let totalNet = 0;
let total19:number = 0;
let total7:number = 0;
let net19 = 0;
let net7 = 0;
let net0 = 0;
const rows = Array.isArray(item.rows) ? item.rows : [];
rows.forEach((row: any) => {
if (!['pagebreak', 'title', 'text'].includes(row.mode)) {
const taxPercent = Number(row.taxPercent);
let rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)).toFixed(3);
totalNet = totalNet + Number(rowPrice);
if (row.taxPercent === 19) total19 += Number(rowPrice) * Number(0.19);
else if (row.taxPercent === 7) total7 += Number(rowPrice) * Number(0.07);
if (taxPercent === 19) {
net19 += Number(rowPrice);
total19 += Number(rowPrice) * Number(0.19);
} else if (taxPercent === 7) {
net7 += Number(rowPrice);
total7 += Number(rowPrice) * Number(0.07);
} else if (taxPercent === 0) {
net0 += Number(rowPrice);
}
}
});
return {
totalNet, total19, total7,
totalNet, total19, total7, net19, net7, net0,
totalGross: Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
};
};
@@ -57,6 +72,32 @@ const displayCurrency = (input: number, onlyAbs = false) => {
return (onlyAbs ? Math.abs(input) : input).toFixed(2).replace(".", ",");
};
const DATEV_TIMEZONE = "Europe/Berlin";
const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
if (!date) return "";
const parsed = dayjs(date);
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : "";
};
const getCreatedDocumentRevenueLines = (document: any) => {
const totals = getCreatedDocumentTotal(document);
if (document.taxType === "13b UStG") {
return [{ account: "8337", amount: totals.totalGross }];
}
if (document.taxType === "19 UStG") {
return [{ account: "8192", amount: totals.totalGross }];
}
return [
{ account: "8400", amount: Number(totals.net19.toFixed(2)) },
{ account: "8334", amount: Number(totals.net7.toFixed(2)) },
{ account: "8290", amount: Number(totals.net0.toFixed(2)) },
].filter((line) => line.amount !== 0);
};
// ---------------------------------------------------------
// MAIN EXPORT FUNCTION
// ---------------------------------------------------------
@@ -76,8 +117,10 @@ export async function buildExportZip(
// Header Infos
const dateNowStr = dayjs().format("YYYYMMDDHHmmssSSS");
const startDateFmt = dayjs(startDate).format("YYYYMMDD");
const endDateFmt = dayjs(endDate).format("YYYYMMDD");
const startDateValue = formatDatevDate(startDate, "YYYY-MM-DD");
const endDateValue = formatDatevDate(endDate, "YYYY-MM-DD");
const startDateFmt = formatDatevDate(startDate, "YYYYMMDD");
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD");
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
@@ -99,8 +142,8 @@ export async function buildExportZip(
inArray(createddocuments.type, ["invoices", "advanceInvoices", "cancellationInvoices"]),
eq(createddocuments.state, "Gebucht"),
eq(createddocuments.archived, false),
gte(createddocuments.documentDate, startDate),
lte(createddocuments.documentDate, endDate)
gte(createddocuments.documentDate, startDateValue),
lte(createddocuments.documentDate, endDateValue)
));
// Mapping: Flat Result -> Nested Object (damit der Rest des Codes gleich bleiben kann)
@@ -121,8 +164,8 @@ export async function buildExportZip(
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.state, "Gebucht"),
eq(incominginvoices.archived, false),
gte(incominginvoices.date, startDate),
lte(incominginvoices.date, endDate)
gte(incominginvoices.date, startDateValue),
lte(incominginvoices.date, endDateValue)
));
const incominginvoicesList = iiRaw.map(r => ({
@@ -136,6 +179,10 @@ export async function buildExportZip(
const CdCustomer = aliasedTable(customers, "cd_customer");
const IiVendor = aliasedTable(vendors, "ii_vendor");
const ContraAccount = aliasedTable(accounts, "contra_account");
const ContraVendor = aliasedTable(vendors, "contra_vendor");
const ContraCustomer = aliasedTable(customers, "contra_customer");
const ContraOwnaccount = aliasedTable(ownaccounts, "contra_ownaccount");
const allocRaw = await server.db.select({
allocation: statementallocations,
@@ -148,11 +195,15 @@ export async function buildExportZip(
acc: accounts,
direct_vend: vendors, // Direkte Zuordnung an Kreditor
direct_cust: customers, // Direkte Zuordnung an Debitor
own: ownaccounts
own: ownaccounts,
contra_acc: ContraAccount,
contra_vend: ContraVendor,
contra_cust: ContraCustomer,
contra_own: ContraOwnaccount
})
.from(statementallocations)
// JOIN 1: Bankstatement (Pflicht, für Datum Filter)
.innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
// JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz)
.leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
// JOIN 2: Bankaccount (für DATEV Nummer)
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
@@ -169,13 +220,25 @@ export async function buildExportZip(
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
.leftJoin(ContraOwnaccount, eq(statementallocations.contraOwnaccount, ContraOwnaccount.id))
.where(and(
eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false),
// Datum Filter direkt auf dem Bankstatement
gte(bankstatements.date, startDate),
lte(bankstatements.date, endDate)
or(
and(
gte(bankstatements.date, startDateValue),
lte(bankstatements.date, endDateValue)
),
and(
isNull(statementallocations.bankstatement),
gte(statementallocations.manualBookingDate, startDateValue),
lte(statementallocations.manualBookingDate, endDateValue)
)
)
));
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
@@ -196,7 +259,11 @@ export async function buildExportZip(
account: r.acc,
vendor: r.direct_vend,
customer: r.direct_cust,
ownaccount: r.own
ownaccount: r.own,
contraAccount: r.contra_acc,
contraVendor: r.contra_vend,
contraCustomer: r.contra_cust,
contraOwnaccount: r.contra_own
}));
// --- D) Stammdaten Accounts ---
@@ -265,24 +332,23 @@ export async function buildExportZip(
// AR
createddocumentsList.forEach(cd => {
let file = filesCreateddocuments.find(i => i.createddocument === cd.id);
let total = 0;
let typeString = "";
if(cd.type === "invoices") {
total = getCreatedDocumentTotal(cd).totalGross;
typeString = "AR";
} else if(cd.type === "advanceInvoices") {
total = getCreatedDocumentTotal(cd).totalGross;
typeString = "ARAbschlag";
} else if(cd.type === "cancellationInvoices") {
total = getCreatedDocumentTotal(cd).totalGross;
typeString = "ARStorno";
}
let shSelector = Math.sign(total) === -1 ? "H" : "S";
const cust = cd.customer; // durch Mapping verfügbar
const revenueLines = getCreatedDocumentRevenueLines(cd);
bookingLines.push(`${displayCurrency(total,true)};"${shSelector}";;;;;${cust?.customerNumber || ""};8400;"";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`${typeString} ${cd.documentNumber} - ${cust?.name || ""}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${cust?.name || ""}";"Kundennummer";"${cust?.customerNumber || ""}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(cd.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
revenueLines.forEach((revenueLine) => {
let shSelector = Math.sign(revenueLine.amount) === -1 ? "H" : "S";
bookingLines.push(`${displayCurrency(revenueLine.amount,true)};"${shSelector}";;;;;${cust?.customerNumber || ""};${revenueLine.account};"";${formatDatevDate(cd.documentDate, "DDMM")};"${cd.documentNumber}";;;"${`${typeString} ${cd.documentNumber} - ${cust?.name || ""}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${cust?.name || ""}";"Kundennummer";"${cust?.customerNumber || ""}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${formatDatevDate(cd.deliveryDate, "DD.MM.YYYY")}";"Belegdatum";"${formatDatevDate(cd.documentDate, "DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
});
});
// ER
@@ -306,30 +372,64 @@ export async function buildExportZip(
let text = `ER ${ii.reference}: ${escapeString(ii.description)}`.substring(0,59);
const vend = ii.vendor; // durch Mapping verfügbar
bookingLines.push(`${Math.abs(amountGross).toFixed(2).replace(".",",")};"${shSelector}";;;;;${accountData.number};${vend?.vendorNumber || ""};"${buschluessel}";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${text}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${vend?.name || ""}";"Kundennummer";"${vend?.vendorNumber || ""}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
bookingLines.push(`${Math.abs(amountGross).toFixed(2).replace(".",",")};"${shSelector}";;;;;${accountData.number};${vend?.vendorNumber || ""};"${buschluessel}";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${text}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${vend?.name || ""}";"Kundennummer";"${vend?.vendorNumber || ""}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
});
});
// Bank
const getManualBookingSide = (alloc: any, side: "debit" | "credit") => {
const prefix = side === "credit" ? "contra" : "";
const account = side === "credit" ? alloc.contraAccount : alloc.account;
const vendor = side === "credit" ? alloc.contraVendor : alloc.vendor;
const customer = side === "credit" ? alloc.contraCustomer : alloc.customer;
const ownaccount = side === "credit" ? alloc.contraOwnaccount : alloc.ownaccount;
const incominginvoice = alloc.manualInvoiceSide === side ? alloc.incominginvoice : null;
if (account) return { number: account.number, name: account.label, type: "Sachkonto" };
if (vendor) return { number: vendor.vendorNumber, name: vendor.name, type: "Kreditor" };
if (customer) return { number: customer.customerNumber, name: customer.name, type: "Debitor" };
if (ownaccount) return { number: ownaccount.number, name: ownaccount.name, type: "Eigenes Konto" };
if (incominginvoice) {
return {
number: incominginvoice.vendor?.vendorNumber || "",
name: `${incominginvoice.reference || "Eingangsbeleg"} ${incominginvoice.vendor?.name || ""}`.trim(),
type: "Eingangsbeleg",
reference: incominginvoice.reference || "",
};
}
return { number: "", name: "", type: prefix };
};
statementallocationsList.forEach(alloc => {
const bs = alloc.bankstatement; // durch Mapping verfügbar
if(!bs && alloc.manualBookingDate) {
const debit = getManualBookingSide(alloc, "debit");
const credit = getManualBookingSide(alloc, "credit");
const dateManual = formatDatevDate(alloc.manualBookingDate, "DDMM");
const dateManualFull = formatDatevDate(alloc.manualBookingDate, "DD.MM.YYYY");
const belegnummer = debit.reference || credit.reference || "";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"${alloc.datevTaxKey || ""}";${dateManual};"${belegnummer}";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"${belegnummer}";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
return;
}
if(!bs) return;
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
// @ts-ignore
let datevKonto = bs.account?.datevNumber || "";
let dateVal = dayjs(bs.date).format("DDMM");
let dateFull = dayjs(bs.date).format("DD.MM.YYYY");
let dateVal = formatDatevDate(bs.date, "DDMM");
let dateFull = formatDatevDate(bs.date, "DD.MM.YYYY");
let bsText = escapeString(bs.text);
if(alloc.createddocument && alloc.createddocument.customer) {
const cd = alloc.createddocument;
const cust = cd.customer;
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"3";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"";${formatDatevDate(cd.documentDate, "DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${formatDatevDate(cd.deliveryDate, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
const ii = alloc.incominginvoice;
const vend = ii.vendor;
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.account) {
const acc = alloc.account;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
@@ -425,4 +525,4 @@ export async function buildExportZip(
console.error("DATEV Export Error:", error);
throw error;
}
}
}

View File

@@ -1,127 +1,268 @@
import xmlbuilder from "xmlbuilder";
import {randomUUID} from "node:crypto";
import { randomUUID } from "node:crypto";
import dayjs from "dayjs";
import { and, eq, inArray } from "drizzle-orm";
import { createddocuments, tenants } from "../../../db/schema";
import {
bankaccounts,
createddocuments,
customers,
entitybankaccounts,
outgoingsepamandates,
tenants,
} from "../../../db/schema";
import { decrypt } from "../crypt";
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
const data = await server.db
.select()
.from(createddocuments)
.where(and(
eq(createddocuments.tenant, tenant_id),
inArray(createddocuments.id, idsToExport)
))
const getCreatedDocumentTotal = (item: any) => {
let totalNet = 0;
let total19 = 0;
let total7 = 0;
const tenantRows = await server.db
.select()
.from(tenants)
.where(eq(tenants.id, tenant_id))
.limit(1)
const tenantData = tenantRows[0]
console.log(tenantData)
const rows = Array.isArray(item.rows) ? item.rows : [];
rows.forEach((row: any) => {
if (!["pagebreak", "title", "text"].includes(row.mode)) {
const rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)).toFixed(3);
totalNet += Number(rowPrice);
console.log(data)
let transactions = []
let obj = {
Document: {
'@xmlns':"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
'CstmrDrctDbtInitn': {
'GrpHdr': {
'MsgId': randomUUID(),
'CredDtTm': dayjs().format("YYYY-MM-DDTHH:mm:ss"),
'NbOfTxs': transactions.length,
'CtrlSum': 0, // TODO: Total Sum
'InitgPty': {
'Nm': tenantData.name
}
},
'PmtInf': {
'PmtInfId': "", // TODO: Mandatsreferenz,
'PmtMtd': "DD",
'BtchBookg': "true", // TODO: BatchBooking,
'NbOfTxs': transactions.length,
'CtrlSum': 0, //TODO: Total Sum
'PmtTpInf': {
'SvcLvl': {
'Cd': "SEPA"
},
'LclInstrm': {
'Cd': "CORE" // Core für BASIS / B2B für Firmen
},
'SeqTp': "RCUR" // TODO: Unterscheidung Einmalig / Wiederkehrend
},
'ReqdColltnDt': dayjs().add(3, "days").format("YYYY-MM-DDTHH:mm:ss"),
'Cdtr': {
'Nm': tenantData.name
},
'CdtrAcct': {
'Id': {
'IBAN': "DE" // TODO: IBAN Gläubiger EINSETZEN
}
},
'CdtrAgt': {
'FinInstnId': {
'BIC': "BIC" // TODO: BIC des Gläubigers einsetzen
}
},
'ChrgBr': "SLEV",
'CdtrSchmeId': {
'Id': {
'PrvtId': {
'Othr': {
'Id': tenantData.creditorId,
'SchmeNm': {
'Prty': "SEPA"
}
}
}
}
},
//TODO ITERATE ALL INVOICES HERE
'DrctDbtTxInf': {
'PmtId': {
'EndToEndId': "" // TODO: EINDEUTIGE ReFERENZ wahrscheinlich RE Nummer
},
'InstdAmt': {
'@Ccy':"EUR",
'#text':100 //TODO: Rechnungssumme zwei NK mit Punkt
},
'DrctDbtTx': {
'MndtRltdInf': {
'MndtId': "", // TODO: Mandatsref,
'DtOfSgntr': "", //TODO: Unterschrieben am,
'AmdmntInd': "" //TODO: Mandat geändert
}
},
'DbtrAgt': {
'FinInstnId': {
'BIC': "", //TODO: BIC Debtor
}
},
'Dbtr': {
'Nm': "" // TODO NAME Debtor
},
'DbtrAcct': {
'Id': {
'IBAN': "DE" // TODO IBAN Debtor
}
},
'RmtInf': {
'Ustrd': "" //TODO Verwendungszweck Rechnungsnummer
}
}
}
if (Number(row.taxPercent) === 19) {
total19 += Number(rowPrice) * 0.19;
} else if (Number(row.taxPercent) === 7) {
total7 += Number(rowPrice) * 0.07;
}
}
});
return Number((Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))).toFixed(2));
};
const cleanIban = (value: string | null | undefined) => (value || "").replace(/\s+/g, "").toUpperCase();
const cleanBic = (value: string | null | undefined) => (value || "").replace(/\s+/g, "").toUpperCase();
const formatAmount = (value: number) => value.toFixed(2);
const sanitizeText = (value: string | null | undefined, maxLength = 140) => {
return (value || "")
.replace(/[\n\r;]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, maxLength);
};
const getDecryptedEntityBankAccount = (row: typeof entitybankaccounts.$inferSelect) => ({
iban: cleanIban(decrypt(row.ibanEncrypted as any)),
bic: cleanBic(decrypt(row.bicEncrypted as any)),
bankName: decrypt(row.bankNameEncrypted as any),
});
const buildDirectDebitTransaction = (item: any) => {
const amount = getCreatedDocumentTotal(item.document);
if (amount <= 0) {
throw new Error(`Beleg ${item.document.documentNumber || item.document.id} hat keinen positiven Zahlungsbetrag.`);
}
const debtorBankAccount = getDecryptedEntityBankAccount(item.debtorBankAccount);
if (!debtorBankAccount.iban || !debtorBankAccount.bic) {
throw new Error(`Bankverbindung für Mandat ${item.mandate.reference} ist unvollständig.`);
}
let doc = xmlbuilder.create(obj, {encoding: 'UTF-8', standalone: true})
if (!item.mandate.signedAt) {
throw new Error(`Mandat ${item.mandate.reference} hat kein Unterschriftsdatum.`);
}
console.log(doc.end({pretty:true}))
return {
amount,
xml: {
PmtId: {
EndToEndId: sanitizeText(item.document.documentNumber || `Beleg-${item.document.id}`, 35),
},
InstdAmt: {
"@Ccy": "EUR",
"#text": formatAmount(amount),
},
DrctDbtTx: {
MndtRltdInf: {
MndtId: sanitizeText(item.mandate.reference, 35),
DtOfSgntr: dayjs(item.mandate.signedAt).format("YYYY-MM-DD"),
AmdmntInd: "false",
},
},
DbtrAgt: {
FinInstnId: {
BIC: debtorBankAccount.bic,
},
},
Dbtr: {
Nm: sanitizeText(item.customer.name, 70),
},
DbtrAcct: {
Id: {
IBAN: debtorBankAccount.iban,
},
},
RmtInf: {
Ustrd: sanitizeText(`Rechnung ${item.document.documentNumber || item.document.id}`),
},
},
};
};
}
export const createSEPAExport = async (
server: any,
idsToExport: number[],
tenantId: number,
creditorBankaccountId: number
) => {
if (!idsToExport.length) {
throw new Error("Es wurden keine Belege für den SEPA-Export ausgewählt.");
}
const [tenantData] = await server.db
.select()
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
if (!tenantData?.creditorId) {
throw new Error("Für den Mandanten ist keine Gläubiger-ID hinterlegt.");
}
const [creditorBankAccount] = await server.db
.select()
.from(bankaccounts)
.where(and(
eq(bankaccounts.id, creditorBankaccountId),
eq(bankaccounts.tenant, tenantId)
))
.limit(1);
if (!creditorBankAccount) {
throw new Error("Das ausgewählte Gläubigerkonto wurde nicht gefunden.");
}
const rows = await server.db
.select({
document: createddocuments,
customer: customers,
mandate: outgoingsepamandates,
debtorBankAccount: entitybankaccounts,
})
.from(createddocuments)
.innerJoin(customers, eq(createddocuments.customer, customers.id))
.innerJoin(outgoingsepamandates, eq(createddocuments.outgoingsepamandate, outgoingsepamandates.id))
.innerJoin(entitybankaccounts, eq(outgoingsepamandates.bankaccount, entitybankaccounts.id))
.where(and(
eq(createddocuments.tenant, tenantId),
eq(createddocuments.payment_type, "direct-debit"),
inArray(createddocuments.id, idsToExport)
));
if (rows.length !== idsToExport.length) {
throw new Error("Nicht alle ausgewählten Belege sind gültige SEPA-Lastschrift-Belege mit Mandat.");
}
const invalidMandate = rows.find((row) => row.mandate.tenant !== tenantId || row.mandate.status !== "Aktiv" || row.mandate.archived);
if (invalidMandate) {
throw new Error(`Mandat ${invalidMandate.mandate.reference} ist nicht aktiv oder gehört nicht zum Mandanten.`);
}
const transactions = rows.map((row) => ({
...row,
transaction: buildDirectDebitTransaction(row),
}));
const totalAmount = Number(transactions.reduce((sum, item) => sum + item.transaction.amount, 0).toFixed(2));
const messageId = randomUUID();
const collectionDate = dayjs().add(3, "days").format("YYYY-MM-DD");
const createdAt = dayjs().format("YYYY-MM-DDTHH:mm:ss");
const creditorIban = cleanIban(creditorBankAccount.iban);
const creditorBic = cleanBic(creditorBankAccount.bankId);
type SepaTransaction = (typeof transactions)[number];
const groupedTransactions: Record<string, SepaTransaction[]> = {};
transactions.forEach((item) => {
const key = `${item.mandate.mandateType || "CORE"}-${item.mandate.sequenceType || "RCUR"}`;
groupedTransactions[key] = groupedTransactions[key] || [];
groupedTransactions[key].push(item);
});
const paymentInformations = Object.entries(groupedTransactions).map(([key, items], index) => {
const groupTotal = Number(items.reduce((sum, item) => sum + item.transaction.amount, 0).toFixed(2));
const [mandateType, sequenceType] = key.split("-");
return {
PmtInfId: sanitizeText(`${messageId}-${index + 1}`, 35),
PmtMtd: "DD",
BtchBookg: "true",
NbOfTxs: items.length,
CtrlSum: formatAmount(groupTotal),
PmtTpInf: {
SvcLvl: {
Cd: "SEPA",
},
LclInstrm: {
Cd: mandateType,
},
SeqTp: sequenceType,
},
ReqdColltnDt: collectionDate,
Cdtr: {
Nm: sanitizeText(tenantData.name, 70),
},
CdtrAcct: {
Id: {
IBAN: creditorIban,
},
},
CdtrAgt: {
FinInstnId: {
BIC: creditorBic,
},
},
ChrgBr: "SLEV",
CdtrSchmeId: {
Id: {
PrvtId: {
Othr: {
Id: tenantData.creditorId,
SchmeNm: {
Prtry: "SEPA",
},
},
},
},
},
DrctDbtTxInf: items.map((item) => item.transaction.xml),
};
});
const obj = {
Document: {
"@xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
CstmrDrctDbtInitn: {
GrpHdr: {
MsgId: sanitizeText(messageId, 35),
CreDtTm: createdAt,
NbOfTxs: transactions.length,
CtrlSum: formatAmount(totalAmount),
InitgPty: {
Nm: sanitizeText(tenantData.name, 70),
},
},
PmtInf: paymentInformations,
},
},
};
const xml = xmlbuilder.create(obj, { encoding: "UTF-8", standalone: true }).end({ pretty: true });
const documentDates = rows.map((row) => dayjs(row.document.documentDate)).filter((date) => date.isValid());
const startDate = documentDates.reduce((min, date) => date.isBefore(min) ? date : min, documentDates[0] || dayjs());
const endDate = documentDates.reduce((max, date) => date.isAfter(max) ? date : max, documentDates[0] || dayjs());
return {
buffer: Buffer.from(xml, "utf-8"),
startDate: startDate.toDate(),
endDate: endDate.toDate(),
count: transactions.length,
totalAmount,
};
};

View File

@@ -12,6 +12,13 @@ export const useNextNumberRangeNumber = async (
tenantId: number,
numberRange: string
) => {
const numberRangeFallbacks: Record<string, string> = {
costEstimates: "quotes",
packingSlips: "deliveryNotes",
advanceInvoices: "invoices",
cancellationInvoices: "invoices",
}
const [tenant] = await server.db
.select()
.from(tenants)
@@ -23,11 +30,15 @@ export const useNextNumberRangeNumber = async (
const numberRanges = tenant.numberRanges || {}
if (!numberRanges[numberRange]) {
const resolvedNumberRange = numberRanges[numberRange]
? numberRange
: numberRangeFallbacks[numberRange]
if (!resolvedNumberRange || !numberRanges[resolvedNumberRange]) {
throw new Error(`Number range '${numberRange}' not found`)
}
const current = numberRanges[numberRange]
const current = numberRanges[resolvedNumberRange]
const usedNumber =
(current.prefix || "") +
@@ -37,7 +48,7 @@ export const useNextNumberRangeNumber = async (
const updatedRanges = {
// @ts-ignore
...numberRanges,
[numberRange]: {
[resolvedNumberRange]: {
...current,
nextNumber: current.nextNumber + 1,
},

View File

@@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema";
const HISTORY_ENTITY_LABELS: Record<string, string> = {
customers: "Kunden",
contracts: "Verträge",
members: "Mitglieder",
vendors: "Lieferanten",
projects: "Projekte",
@@ -32,6 +33,8 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
incominginvoices: "Eingangsrechnungen",
files: "Dateien",
memberrelations: "Mitgliedsverhältnisse",
teams: "Teams",
outgoingsepamandates: "Ausgehende SEPA-Mandate",
}
export function getHistoryEntityLabel(entity: string) {
@@ -62,6 +65,7 @@ export async function insertHistoryItem(
const columnMap: Record<string, string> = {
customers: "customer",
contracts: "contract",
members: "customer",
vendors: "vendor",
projects: "project",
@@ -91,6 +95,7 @@ export async function insertHistoryItem(
incominginvoices: "incomingInvoice",
files: "file",
memberrelations: "memberrelation",
outgoingsepamandates: "outgoingsepamandate",
}
const fkColumn = columnMap[params.entity]

View File

@@ -0,0 +1,839 @@
import dayjs from "dayjs";
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import { and, desc, eq, gte } from "drizzle-orm";
import { FastifyInstance } from "fastify";
import { createHash } from "node:crypto";
import {
bankaccounts,
bankstatements,
createddocuments,
incominginvoices,
statementallocations,
tenants,
} from "../../db/schema";
import { secrets } from "./secrets";
type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement" | "serial_template";
type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly";
type ForecastEvent = {
date: string;
amount: number;
label: string;
source: ForecastEventSource;
sourceId?: number | string | null;
recurringKey?: string;
confidence?: number;
};
type TaxBreakdown = {
net19: number;
tax19: number;
net7: number;
tax7: number;
net0: number;
};
type TaxForecastPeriod = {
key: string;
label: string;
range: string;
dueDate: string;
outputTax: number;
inputTax: number;
balance: number;
outputCount: number;
inputCount: number;
};
type RecurringCandidate = {
key?: string;
label: string;
amount: number;
interval: "weekly" | "monthly" | "quarterly" | "yearly";
nextDate: string;
confidence: number;
evidence: string;
};
const AiRecurringFormat = z.object({
candidates: z.array(z.object({
label: z.string(),
amount: z.number(),
interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]),
nextDate: z.string(),
confidence: z.number().min(0).max(1),
evidence: z.string(),
})),
});
const FORECAST_DAYS = 90;
const HISTORY_MONTHS = 12;
const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2));
const createZeroTaxBreakdown = (): TaxBreakdown => ({
net19: 0,
tax19: 0,
net7: 0,
tax7: 0,
net0: 0,
});
const normalizeText = (value: unknown) => String(value || "")
.toLowerCase()
.replace(/[^a-z0-9äöüß]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
const normalizeTaxEvaluationPeriod = (value?: string | null): TaxEvaluationPeriod => {
if (value === "quarterly" || value === "yearly") return value;
return "monthly";
};
const isTaxFreeDocument = (taxType?: string | null) => {
return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || ""));
};
const getTaxEvaluationPeriodBounds = (
referenceDate: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const base = dayjs(referenceDate);
if (period === "yearly") {
return {
start: base.startOf("year"),
end: base.endOf("year"),
};
}
if (period === "quarterly") {
const quarterStartMonth = Math.floor(base.month() / 3) * 3;
const start = base.month(quarterStartMonth).startOf("month");
return {
start,
end: start.add(2, "month").endOf("month"),
};
}
return {
start: base.startOf("month"),
end: base.endOf("month"),
};
};
const shiftTaxEvaluationPeriodStart = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod,
offset: number
) => {
const base = dayjs(periodStart);
if (period === "yearly") return base.add(offset, "year").startOf("year");
if (period === "quarterly") return base.add(offset * 3, "month").startOf("month");
return base.add(offset, "month").startOf("month");
};
const formatTaxEvaluationPeriodLabel = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const { start } = getTaxEvaluationPeriodBounds(periodStart, period);
if (period === "yearly") {
return start.format("YYYY");
}
if (period === "quarterly") {
return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}`;
}
return start.format("MMMM YYYY");
};
const formatTaxEvaluationPeriodRange = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period);
return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}`;
};
const getTaxSettlementDate = (periodEnd: dayjs.Dayjs) => {
return periodEnd.add(1, "month").date(10).startOf("day");
};
const getRecurringKey = (candidate: RecurringCandidate) => {
const rawKey = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label),
candidate.interval,
].join("|");
return createHash("sha1").update(rawKey).digest("hex").slice(0, 16);
};
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
if (interval === "weekly") return date.add(1, "week");
if (interval === "quarterly") return date.add(3, "month");
if (interval === "yearly") return date.add(1, "year");
return date.add(1, "month");
};
const addSerialInterval = (date: dayjs.Dayjs, interval: string) => {
if (interval === "wöchentlich") return date.add(1, "week");
if (interval === "2 - wöchentlich") return date.add(2, "week");
if (interval === "vierteljährlich") return date.add(3, "month");
if (interval === "halbjährlich") return date.add(6, "month");
if (interval === "jährlich") return date.add(1, "year");
return date.add(1, "month");
};
const getStatementPartner = (statement: any) => {
return statement.amount < 0
? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe"
: statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme";
};
const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => {
let totalNet = 0;
let totalTax = 0;
(document.rows || []).forEach((row: any) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return;
const rowNet = Number(
(Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)
);
const taxPercent = Number(row.taxPercent);
totalNet += rowNet;
totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100;
});
let advancePayments = 0;
(document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => {
const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId);
const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData);
if (!advanceRow) return;
advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100);
});
return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments);
};
const getCreatedDocumentTaxBreakdown = (document: any): TaxBreakdown => {
const breakdown = createZeroTaxBreakdown();
if (!document || isTaxFreeDocument(document.taxType)) {
return breakdown;
}
(document.rows || []).forEach((row: any) => {
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return;
const quantity = Number(row.quantity || 0);
const price = Number(row.price || 0);
const discountPercent = Number(row.discountPercent || 0);
const taxPercent = Number(row.taxPercent || 0);
const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2));
if (!Number.isFinite(net) || net === 0) return;
if (taxPercent === 19) {
breakdown.net19 += net;
breakdown.tax19 += Number((net * 0.19).toFixed(2));
} else if (taxPercent === 7) {
breakdown.net7 += net;
breakdown.tax7 += Number((net * 0.07).toFixed(2));
} else {
breakdown.net0 += net;
}
});
return {
net19: roundMoney(breakdown.net19),
tax19: roundMoney(breakdown.tax19),
net7: roundMoney(breakdown.net7),
tax7: roundMoney(breakdown.tax7),
net0: roundMoney(breakdown.net0),
};
};
const getIncomingInvoiceTaxBreakdown = (invoice: any): TaxBreakdown => {
const breakdown = createZeroTaxBreakdown();
(invoice?.accounts || []).forEach((account: any) => {
const taxType = String(account?.taxType || "");
const amountNet = Number(account?.amountNet || 0);
const amountTax = Number(account?.amountTax || 0);
if (taxType === "19") {
breakdown.net19 += amountNet;
breakdown.tax19 += amountTax;
} else if (taxType === "7") {
breakdown.net7 += amountNet;
breakdown.tax7 += amountTax;
} else {
breakdown.net0 += amountNet;
}
});
return {
net19: roundMoney(breakdown.net19),
tax19: roundMoney(breakdown.tax19),
net7: roundMoney(breakdown.net7),
tax7: roundMoney(breakdown.tax7),
net0: roundMoney(breakdown.net0),
};
};
const getIncomingInvoiceSignedAmount = (invoice: any) => {
const amount = (invoice.accounts || []).reduce((sum: number, account: any) => {
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0);
}, 0);
return roundMoney(invoice.expense === false ? amount : amount * -1);
};
const getRemainingSignedAmount = (signedAmount: number, allocatedAmount: number) => {
const remainingAbsolute = Math.max(0, Math.abs(Number(signedAmount || 0)) - Math.abs(Number(allocatedAmount || 0)));
if (remainingAbsolute <= 0.01) return 0;
return roundMoney(Math.sign(Number(signedAmount || 0)) * remainingAbsolute);
};
const findCancellationDocumentIds = (documents: any[]) => {
return new Set(
documents
.filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived)
.map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument)
.filter(Boolean)
);
};
const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => {
if (dates.length < 3) return null;
const gaps = dates
.slice(1)
.map((date, index) => date.diff(dates[index], "day"))
.filter((gap) => gap > 0);
const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1);
if (averageGap >= 6 && averageGap <= 8) return "weekly";
if (averageGap >= 25 && averageGap <= 35) return "monthly";
if (averageGap >= 80 && averageGap <= 100) return "quarterly";
if (averageGap >= 340 && averageGap <= 390) return "yearly";
return null;
};
const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => {
const groups = new Map<string, any[]>();
statements.forEach((statement) => {
const parsedDate = dayjs(statement.valueDate || statement.date);
if (!parsedDate.isValid() || Number(statement.amount || 0) >= 0) return;
const partner = normalizeText(getStatementPartner(statement));
const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" ");
const amountBucket = Math.round(Number(statement.amount || 0) * 100);
const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|");
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(statement);
});
return [...groups.values()]
.map((group) => {
const sorted = group
.map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) }))
.sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf());
const interval = inferInterval(sorted.map((statement) => statement.parsedDate));
if (!interval) return null;
let next = addInterval(sorted[sorted.length - 1].parsedDate, interval);
while (next.isBefore(dayjs(), "day")) {
next = addInterval(next, interval);
}
const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length);
const partner = getStatementPartner(sorted[sorted.length - 1]);
return {
label: partner,
amount,
interval,
nextDate: next.format("YYYY-MM-DD"),
confidence: Math.min(0.9, 0.55 + sorted.length * 0.08),
evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`,
};
})
.filter(Boolean) as RecurringCandidate[];
};
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
const compactStatements = statements.slice(0, 220).map((statement) => ({
date: statement.valueDate || statement.date,
amount: roundMoney(Number(statement.amount || 0)),
partner: getStatementPartner(statement),
text: String(statement.text || "").slice(0, 160),
}));
try {
const completion = await openai.chat.completions.parse({
model: "gpt-4o",
store: true,
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
messages: [
{
role: "system",
content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.",
},
{
role: "user",
content: JSON.stringify({
today: dayjs().format("YYYY-MM-DD"),
horizonDays: FORECAST_DAYS,
bankStatements: compactStatements,
rules: [
"amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.",
"nextDate muss in der Zukunft liegen.",
"Nutze keine einmaligen oder unsicheren Bewegungen.",
],
}),
},
],
});
return (completion.choices[0].message.parsed?.candidates || [])
.filter((candidate) => dayjs(candidate.nextDate).isValid())
.filter((candidate) => Number(candidate.amount || 0) < 0)
.map((candidate) => ({
...candidate,
amount: roundMoney(candidate.amount),
confidence: Math.max(0, Math.min(1, candidate.confidence)),
}));
} catch (error) {
server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden.");
server.log.warn(error);
return [];
}
};
const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => {
const merged = new Map<string, RecurringCandidate>();
[...heuristic, ...ai].forEach((candidate) => {
const key = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label).slice(0, 32),
candidate.interval,
].join("|");
const existing = merged.get(key);
if (!existing || candidate.confidence > existing.confidence) {
merged.set(key, candidate);
}
});
return [...merged.values()]
.map((candidate) => ({
...candidate,
key: getRecurringKey(candidate),
}))
.filter((candidate) => Number(candidate.amount || 0) < 0)
.filter((candidate) => Math.abs(candidate.amount) >= 1)
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
};
const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => {
const events: ForecastEvent[] = [];
recurring.forEach((candidate) => {
let date = dayjs(candidate.nextDate);
let guard = 0;
while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) {
events.push({
date: date.format("YYYY-MM-DD"),
amount: roundMoney(candidate.amount),
label: candidate.label,
source: "recurring_bankstatement",
recurringKey: candidate.key,
confidence: candidate.confidence,
});
date = addInterval(date, candidate.interval);
guard += 1;
}
});
return events;
};
const buildTaxForecastPeriods = (
documents: any[],
incomingInvoices: any[],
periodType: TaxEvaluationPeriod,
today: dayjs.Dayjs,
endDate: dayjs.Dayjs
) => {
const currentBounds = getTaxEvaluationPeriodBounds(today, periodType);
const periods: TaxForecastPeriod[] = [];
let offset = 0;
while (offset < 12) {
const periodStart = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType, offset);
const bounds = getTaxEvaluationPeriodBounds(periodStart, periodType);
const dueDate = getTaxSettlementDate(bounds.end);
if (dueDate.isAfter(endDate, "day")) break;
const outputDocs = documents.filter((document) => {
if (document?.state !== "Gebucht") return false;
if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(document?.type)) return false;
const date = dayjs(document.documentDate);
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
});
const inputDocs = incomingInvoices.filter((invoice) => {
if (invoice?.state !== "Gebucht" || !invoice?.date) return false;
const date = dayjs(invoice.date);
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
});
const outputTax = roundMoney(outputDocs.reduce((sum, document) => {
const breakdown = getCreatedDocumentTaxBreakdown(document);
return sum + breakdown.tax19 + breakdown.tax7;
}, 0));
const inputTax = roundMoney(inputDocs.reduce((sum, invoice) => {
const breakdown = getIncomingInvoiceTaxBreakdown(invoice);
return sum + breakdown.tax19 + breakdown.tax7;
}, 0));
const balance = roundMoney(outputTax - inputTax);
periods.push({
key: bounds.start.format("YYYY-MM-DD"),
label: formatTaxEvaluationPeriodLabel(bounds.start, periodType),
range: formatTaxEvaluationPeriodRange(bounds.start, periodType),
dueDate: dueDate.format("YYYY-MM-DD"),
outputTax,
inputTax,
balance,
outputCount: outputDocs.length,
inputCount: inputDocs.length,
});
offset += 1;
}
return periods;
};
const buildSerialTemplateEvents = (
documents: any[],
today: dayjs.Dayjs,
endDate: dayjs.Dayjs
) => {
const events: ForecastEvent[] = [];
documents
.filter((document) => document.type === "serialInvoices")
.filter((document) => document.serialConfig?.active)
.forEach((document) => {
const firstExecution = dayjs(document.serialConfig?.firstExecution);
const executionUntil = dayjs(document.serialConfig?.executionUntil);
if (!firstExecution.isValid() || !executionUntil.isValid()) return;
const amount = getCreatedDocumentGrossAmount(document, documents);
if (amount <= 0.01) return;
let executionDate = firstExecution.startOf("day");
let guard = 0;
while (executionDate.isBefore(today, "day") && guard < 240) {
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
guard += 1;
}
while (
executionDate.isValid()
&& !executionDate.isAfter(executionUntil, "day")
&& !executionDate.isAfter(endDate, "day")
&& guard < 400
) {
const dueDate = executionDate.add(Number(document.paymentDays || 0), "day");
events.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount,
label: document.documentNumber || document.title || `Serienvorlage ${document.id}`,
source: "serial_template",
sourceId: document.id,
confidence: 0.75,
});
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
guard += 1;
}
});
return events.sort((a, b) => a.date.localeCompare(b.date));
};
export const generateLiquidityForecast = async (
server: FastifyInstance,
tenantId: number,
ignoredRecurringKeys: string[] = []
) => {
const today = dayjs().startOf("day");
const endDate = today.add(FORECAST_DAYS, "day");
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
const [accounts, statements, documents, incomingInvoices, allocations, tenantSettings] = await Promise.all([
server.db
.select()
.from(bankaccounts)
.where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart)))
.orderBy(desc(bankstatements.valueDate)),
server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))),
server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))),
server.db
.select()
.from(statementallocations)
.where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))),
server.db
.select({
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1),
]);
const activeAccounts = accounts.filter((account) => !account.archived);
const activeStatements = statements.filter((statement) => !statement.archived);
const activeDocuments = documents.filter((document) => !document.archived);
const activeIncomingInvoices = incomingInvoices.filter((invoice) => !invoice.archived);
const activeDocumentIds = new Set(activeDocuments.map((document) => document.id));
const activeIncomingInvoiceIds = new Set(activeIncomingInvoices.map((invoice) => invoice.id));
const taxPeriodType = normalizeTaxEvaluationPeriod(tenantSettings[0]?.taxEvaluationPeriod);
const activeAllocations = allocations.filter((allocation) => {
if (allocation.archived) return false;
if (allocation.createddocument && !activeDocumentIds.has(allocation.createddocument)) return false;
if (allocation.incominginvoice && !activeIncomingInvoiceIds.has(allocation.incominginvoice)) return false;
return true;
});
const startingBalance = roundMoney(
activeAccounts
.filter((account) => !account.expired)
.reduce((sum, account) => sum + Number(account.balance || 0), 0)
);
const allocationByDocument = new Map<number, number>();
const allocationByIncomingInvoice = new Map<number, number>();
activeAllocations.forEach((allocation) => {
if (allocation.createddocument) {
allocationByDocument.set(
allocation.createddocument,
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
);
}
if (allocation.incominginvoice) {
allocationByIncomingInvoice.set(
allocation.incominginvoice,
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
);
}
});
const cancelledDocumentIds = findCancellationDocumentIds(activeDocuments);
const openEvents: ForecastEvent[] = [];
const draftEvents: ForecastEvent[] = [];
activeDocuments
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0));
if (openAmount <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`,
source: "open_createddocument",
sourceId: document.id,
confidence: 1,
});
});
activeDocuments
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Entwurf" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
if (total <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
draftEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: total,
label: document.documentNumber || document.title || `Rechnungsentwurf ${document.id}`,
source: "draft_createddocument",
sourceId: document.id,
confidence: 0.35,
});
});
activeIncomingInvoices
.filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet")
.filter((invoice) => !invoice.paid)
.forEach((invoice) => {
const signedAmount = getIncomingInvoiceSignedAmount(invoice);
const openAmount = getRemainingSignedAmount(signedAmount, allocationByIncomingInvoice.get(invoice.id) || 0);
if (Math.abs(openAmount) <= 0.01) return;
const dueDate = dayjs(invoice.dueDate || invoice.date).isValid()
? dayjs(invoice.dueDate || invoice.date)
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`,
source: "open_incominginvoice",
sourceId: invoice.id,
confidence: invoice.state === "Gebucht" ? 1 : 0.8,
});
});
const heuristicRecurring = detectRecurringHeuristically(activeStatements);
const aiRecurring = await detectRecurringWithAi(server, activeStatements);
const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean));
const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring)
.filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key));
const taxPeriods = buildTaxForecastPeriods(activeDocuments, activeIncomingInvoices, taxPeriodType, today, endDate);
const taxEvents: ForecastEvent[] = taxPeriods
.filter((period) => Math.abs(period.balance) > 0.01)
.map((period) => ({
date: period.dueDate,
amount: roundMoney(period.balance * -1),
label: `USt ${period.label}`,
source: "tax_settlement" as const,
sourceId: period.key,
confidence: 0.95,
}));
const serialTemplateEvents = buildSerialTemplateEvents(activeDocuments, today, endDate);
const events = [
...openEvents,
...taxEvents,
...serialTemplateEvents,
...expandRecurringEvents(recurring, endDate),
].filter((event) => {
const date = dayjs(event.date);
return date.isValid() && !date.isAfter(endDate, "day");
});
const dailyEvents = new Map<string, ForecastEvent[]>();
events.forEach((event) => {
if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []);
dailyEvents.get(event.date)!.push(event);
});
let runningBalance = startingBalance;
const points = [];
for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) {
const date = today.add(offset, "day").format("YYYY-MM-DD");
const dayEvents = dailyEvents.get(date) || [];
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
runningBalance = roundMoney(runningBalance + income + expense);
points.push({
date,
balance: runningBalance,
income,
expense,
events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)),
});
}
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]);
const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
const draftIncome = roundMoney(draftEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
return {
generatedAt: new Date().toISOString(),
horizonDays: FORECAST_DAYS,
startingBalance,
endingBalance: points[points.length - 1]?.balance || startingBalance,
lowestBalance: lowestPoint?.balance || startingBalance,
lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"),
totalIncome,
totalExpense,
accounts: activeAccounts.map((account) => ({
id: account.id,
name: account.name,
iban: account.iban,
balance: roundMoney(Number(account.balance || 0)),
expired: account.expired,
syncedAt: account.syncedAt,
})),
recurring,
events: events.sort((a, b) => a.date.localeCompare(b.date)),
draftEvents: draftEvents.sort((a, b) => a.date.localeCompare(b.date)),
draftIncome,
tax: {
periodType: taxPeriodType,
periods: taxPeriods,
totalBalance: roundMoney(taxPeriods.reduce((sum, period) => sum + period.balance, 0)),
},
points,
ai: {
enabled: Boolean(secrets.OPENAI_API_KEY),
candidates: aiRecurring.length,
},
};
};

View File

@@ -58,6 +58,8 @@ const getDuration = (time) => {
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
const isPackingSlip = invoiceData?.type === "packingSlips"
const genPDF = async (invoiceData, backgroundSourceBuffer) => {
const pdfDoc = await PDFDocument.create()
@@ -347,7 +349,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold
})
if (invoiceData.type !== "deliveryNotes") {
if (isPackingSlip) {
pages[pageCounter - 1].drawText("Check", {
...getCoordinatesForPDFLib(180, 137, page1),
size: 12,
color: rgb(0, 0, 0),
lineHeight: 12,
opacity: 1,
maxWidth: 240,
font: fontBold
})
}
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText("Steuer", {
...getCoordinatesForPDFLib(135, 137, page1),
size: 12,
@@ -414,9 +428,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
maxWidth: 240
})
if (isPackingSlip) {
pages[pageCounter - 1].drawRectangle({
...getCoordinatesForPDFLib(182, rowHeight + 1, page1),
width: 12,
height: 12,
borderColor: rgb(0, 0, 0),
borderWidth: 0.8,
opacity: 1,
borderOpacity: 1,
})
}
let rowTextLines = 0
if (invoiceData.type !== "deliveryNotes") {
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight, page1),
size: 10,
@@ -428,7 +454,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
rowTextLines = splitStringBySpace(row.text, 35).length
} else {
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 80).join("\n"), {
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, isPackingSlip ? 68 : 80).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight, page1),
size: 10,
color: rgb(0, 0, 0),
@@ -437,13 +463,13 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold
})
rowTextLines = splitStringBySpace(row.text, 80).length
rowTextLines = splitStringBySpace(row.text, isPackingSlip ? 68 : 80).length
}
let rowDescriptionLines = 0
if (row.descriptionText) {
if (invoiceData.type !== "deliveryNotes") {
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
@@ -454,8 +480,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
})
} else {
rowDescriptionLines = splitStringBySpace(row.descriptionText, 80).length
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 80).join("\n"), {
rowDescriptionLines = splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).length
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).join("\n"), {
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
size: 10,
color: rgb(0, 0, 0),
@@ -466,7 +492,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}
if (invoiceData.type !== "deliveryNotes") {
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawText(`${row.taxPercent} %`, {
...getCoordinatesForPDFLib(135, rowHeight, page1),
size: 10,
@@ -632,7 +658,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold
})
if (invoiceData.type !== "deliveryNotes") {
if (isPackingSlip) {
page.drawText("Check", {
...getCoordinatesForPDFLib(180, 22, page1),
size: 12,
color: rgb(0, 0, 0),
lineHeight: 12,
opacity: 1,
maxWidth: 240,
font: fontBold
})
}
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
page.drawText("Steuer", {
...getCoordinatesForPDFLib(135, 22, page1),
size: 12,
@@ -742,7 +780,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
let endTextDiff = 35
if (invoiceData.type !== "deliveryNotes") {
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawLine({
start: getCoordinatesForPDFLib(20, rowHeight, page1),
end: getCoordinatesForPDFLib(198, rowHeight, page1),
@@ -864,10 +902,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
opacity: 1,
maxWidth: 500
})
return await pdfDoc.saveAsBase64()
}
return await pdfDoc.saveAsBase64()
}
const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath))
@@ -1138,4 +1176,4 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
console.log(error)
throw error; // Fehler weiterwerfen, damit er oben ankommt
}
}
}

View File

@@ -0,0 +1,142 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfileBranches, authProfiles, branches } from "../../db/schema"
function normalizeBranchIds(values: any[]): number[] {
return [...new Set(
values
.map((value) => {
if (typeof value === "number") return value
if (typeof value === "string" && value.trim()) return Number(value)
if (value && typeof value === "object" && "id" in value) return Number(value.id)
return NaN
})
.filter((value) => Number.isFinite(value))
)]
}
export async function enrichProfilesWithBranches(server: FastifyInstance, profiles: any[]) {
if (!profiles.length) return profiles
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
if (!profileIds.length) return profiles
const profileBranchRows = await server.db
.select()
.from(authProfileBranches)
.where(inArray(authProfileBranches.profile_id, profileIds))
const branchIds = [...new Set(profileBranchRows.map((row) => row.branch_id).filter(Boolean))]
const branchRows = branchIds.length
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
: []
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
const branchIdsByProfile = new Map<string, number[]>()
for (const row of profileBranchRows) {
const current = branchIdsByProfile.get(row.profile_id) || []
current.push(row.branch_id)
branchIdsByProfile.set(row.profile_id, current)
}
return profiles.map((profile) => {
const assignedBranchIds = [...new Set(branchIdsByProfile.get(profile.id) || [])]
return {
...profile,
branch: profile.branch_id ? branchMap.get(profile.branch_id) || null : null,
branches: assignedBranchIds
.map((branchId) => branchMap.get(branchId))
.filter(Boolean),
branch_ids: assignedBranchIds,
}
})
}
export async function loadProfileWithBranches(server: FastifyInstance, profileId: string, tenantId: number) {
const rows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.id, profileId),
eq(authProfiles.tenant_id, tenantId)
)
)
.limit(1)
if (!rows.length) return null
const [profile] = await enrichProfilesWithBranches(server, rows)
return profile
}
export async function resolveTenantBranchIds(
server: FastifyInstance,
tenantId: number,
values: any[],
primaryBranchId?: any
) {
const normalizedPrimaryBranchId = primaryBranchId == null || primaryBranchId === ""
? null
: Number(primaryBranchId)
const requestedBranchIds = normalizeBranchIds([
...values,
normalizedPrimaryBranchId,
])
if (!requestedBranchIds.length) {
return {
primaryBranchId: normalizedPrimaryBranchId,
branchIds: [],
}
}
const validBranches = await server.db
.select({ id: branches.id })
.from(branches)
.where(
and(
eq(branches.tenant, tenantId),
inArray(branches.id, requestedBranchIds)
)
)
const validBranchIds = validBranches.map((branch) => branch.id)
if (validBranchIds.length !== requestedBranchIds.length) {
throw new Error("INVALID_BRANCH_SELECTION")
}
if (normalizedPrimaryBranchId != null && !validBranchIds.includes(normalizedPrimaryBranchId)) {
throw new Error("INVALID_PRIMARY_BRANCH")
}
return {
primaryBranchId: normalizedPrimaryBranchId,
branchIds: validBranchIds,
}
}
export async function syncProfileBranches(
server: FastifyInstance,
profileId: string,
branchIds: number[],
userId?: string | null
) {
await server.db
.delete(authProfileBranches)
.where(eq(authProfileBranches.profile_id, profileId))
if (!branchIds.length) return
await server.db
.insert(authProfileBranches)
.values(branchIds.map((branchId) => ({
profile_id: profileId,
branch_id: branchId,
created_by: userId || null,
})))
}

View File

@@ -0,0 +1,117 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfileTeams, branches, teams } from "../../db/schema"
function normalizeTeamIds(values: any[]): number[] {
return [...new Set(
values
.map((value) => {
if (typeof value === "number") return value
if (typeof value === "string" && value.trim()) return Number(value)
if (value && typeof value === "object" && "id" in value) return Number(value.id)
return NaN
})
.filter((value) => Number.isFinite(value))
)]
}
export async function enrichProfilesWithTeams(server: FastifyInstance, profiles: any[]) {
if (!profiles.length) return profiles
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
if (!profileIds.length) return profiles
const profileTeamRows = await server.db
.select()
.from(authProfileTeams)
.where(inArray(authProfileTeams.profile_id, profileIds))
const teamIds = [...new Set(profileTeamRows.map((row) => row.team_id).filter(Boolean))]
const teamRows = teamIds.length
? await server.db.select().from(teams).where(inArray(teams.id, teamIds))
: []
const branchIds = [...new Set(teamRows.map((team) => team.branch).filter(Boolean))]
const branchRows = branchIds.length
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
: []
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
const teamMap = new Map(teamRows.map((team) => [
team.id,
{
...team,
branch: team.branch ? branchMap.get(team.branch) || null : null,
},
]))
const teamIdsByProfile = new Map<string, number[]>()
for (const row of profileTeamRows) {
const current = teamIdsByProfile.get(row.profile_id) || []
current.push(row.team_id)
teamIdsByProfile.set(row.profile_id, current)
}
return profiles.map((profile) => {
const assignedTeamIds = [...new Set(teamIdsByProfile.get(profile.id) || [])]
return {
...profile,
teams: assignedTeamIds
.map((teamId) => teamMap.get(teamId))
.filter(Boolean),
team_ids: assignedTeamIds,
}
})
}
export async function resolveTenantTeamIds(
server: FastifyInstance,
tenantId: number,
values: any[],
) {
const requestedTeamIds = normalizeTeamIds(values)
if (!requestedTeamIds.length) {
return []
}
const validTeams = await server.db
.select({ id: teams.id })
.from(teams)
.where(
and(
eq(teams.tenant, tenantId),
inArray(teams.id, requestedTeamIds)
)
)
const validTeamIds = validTeams.map((team) => team.id)
if (validTeamIds.length !== requestedTeamIds.length) {
throw new Error("INVALID_TEAM_SELECTION")
}
return validTeamIds
}
export async function syncProfileTeams(
server: FastifyInstance,
profileId: string,
teamIds: number[],
userId?: string | null
) {
await server.db
.delete(authProfileTeams)
.where(eq(authProfileTeams.profile_id, profileId))
if (!teamIds.length) return
await server.db
.insert(authProfileTeams)
.values(teamIds.map((teamId) => ({
profile_id: profileId,
team_id: teamId,
created_by: userId || null,
})))
}

Some files were not shown because too many files have changed in this diff Show More