Compare commits

..

182 Commits

Author SHA1 Message Date
033e74adda KI-AGENT: Matrix-Raumdaten nach Tenant-Import zurücksetzen
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-website (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 10s
2026-06-03 15:06:33 +02:00
ccc66ebd0f KI-AGENT: Matrix-Daten beim Tenant-Import neu provisionieren 2026-06-03 10:43:40 +02:00
c660f62120 KI-AGENT: Veraltete Matrix-Raumreferenzen bereinigen 2026-06-03 10:40:00 +02:00
ad74825781 KI-AGENT: Matrix-Kommunikation im Selfhost-Bootstrap provisionieren
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 10s
Build and Push Docker Images / build-website (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-06-03 10:22:30 +02:00
f1e0f36cca KI-AGENT: Datenbank-URL im Selfhost-Setup absichern 2026-06-03 10:09:43 +02:00
526ad966c4 KI-AGENT: Selfhost-Setup nutzt passende Compose-Datei
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 11s
Build and Push Docker Images / build-frontend (push) Successful in 10s
Build and Push Docker Images / build-website (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-06-03 10:02:34 +02:00
99501fb924 KI-AGENT: Uninstall für Selfhost-Setup ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 12s
Build and Push Docker Images / build-frontend (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 11s
Build and Push Docker Images / build-website (push) Successful in 11s
2026-06-03 09:55:40 +02:00
2fdc89565c KI-AGENT: Encryption-Key im Selfhost-Setup als Hex erzeugen 2026-06-03 09:53:23 +02:00
427c0580c4 Kommunikationsdokumentation ergänzen
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 2m29s
Build and Push Docker Images / build-website (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 57s
Dokumentiert Matrix-Kommunikation, Telekom-Telefonie und den VPS-Asterisk-Entwicklungsbetrieb.
2026-06-03 09:27:20 +02:00
47a9af26fe Tenantdaten vollständig laden
Lädt den aktiven Tenant über die Tenant-Route nach und gibt calendarConfig in der Me-Antwort mit zurück.
2026-06-03 09:27:08 +02:00
42d2d7dc0e Website Docker Compose für Traefik ergänzt 2026-06-03 09:17:18 +02:00
6e0868582a Geräte-Agent als Container verpacken 2026-06-03 09:09:12 +02:00
7a6bb4552e OpenCV Abhängigkeiten für Agent besser verpacken 2026-06-03 09:03:26 +02:00
0ecdff4d7d OpenCV Pipeline für Scan Korrekturen ergänzen 2026-06-02 16:37:38 +02:00
0ea4efdc43 Scan aus der Dateienseite starten 2026-06-02 16:23:27 +02:00
c854b0bf30 Scanner Verwaltung für Geräte-Agenten ergänzen 2026-06-02 15:25:00 +02:00
a26ff30cd8 Geräte-Agent für lokale Scan-Aufträge anlegen 2026-06-02 12:59:04 +02:00
e9504e21e7 Token für Reload zusätzlich lokal sichern 2026-06-02 12:14:16 +02:00
822dcdcfb9 Serverseitigen Login-Redirect vermeiden 2026-06-02 12:06:42 +02:00
78f9bd3f7a Auth-Redirect vor Bootstrap verhindern 2026-06-02 12:03:34 +02:00
4aea8b94c3 Auth-Middleware aus Cookie initialisieren 2026-06-02 12:00:08 +02:00
79d620d9c1 Token-Cookie pfadunabhängig speichern 2026-06-02 11:54:18 +02:00
8d821a6802 Reload während Authentifizierung erlauben 2026-06-02 11:47:45 +02:00
363163f741 KI-AGENT: Datum bei Kassenbucheintrag erlauben 2026-06-01 16:26:57 +02:00
384ea95fe5 Instanzweite Scan-Agenten vorbereiten 2026-05-31 11:52:22 +02:00
2a5071b15a KI-AGENT: Kassenbucheintrag vereinfacht anlegen 2026-05-28 16:43:03 +02:00
f2055d59eb KI-AGENT: Kontenauswahl im Bankportal stabilisieren 2026-05-28 16:38:32 +02:00
b91c9d0fd8 KI-AGENT: Kassenbuch ins Bankportal integrieren 2026-05-28 16:35:50 +02:00
29a9e2b63b KI-AGENT: Gmail-Anhänge-Download-Skript hinzufügen 2026-05-26 21:20:51 +02:00
5264cf54ac Zeilen-Kostenstelle ins Positionsmenü verschieben 2026-05-26 16:30:06 +02:00
f002ad867a Login bei abgelaufener Sitzung korrigieren 2026-05-26 16:27:08 +02:00
cb09651d8d Ausgangsbelege um Kostenstellenzuordnung erweitern 2026-05-26 16:21:24 +02:00
b59599cb92 E-Mail Download direkt navigieren
KI-AGENT: Der Anhang-Download nutzt jetzt eine direkte Browser-Navigation zur Backend-Download-Route, damit der Browser den Attachment-Header sichtbar verarbeitet.
2026-05-23 21:06:46 +02:00
154d7060f8 E-Mail Anhänge ohne Fetch herunterladen
KI-AGENT: Der E-Mail Anhang-Download nutzt jetzt einen nativen Browser-Link statt Cross-Origin-Fetch und erlaubt dafür den bestehenden JWT gezielt als Download-Token.
2026-05-23 21:04:53 +02:00
a34bf43756 E-Mail Anhang-Download CORS absichern
KI-AGENT: OPTIONS-Preflights werden nicht mehr durch den Auth-Hook blockiert und der E-Mail Anhang-Download setzt CORS-Header auch für Fehlerantworten explizit.
2026-05-23 21:02:45 +02:00
2c96b9c5a5 E-Mail Anhänge gezielt per IMAP-Part laden
KI-AGENT: Der Anhang-Download lädt jetzt bevorzugt den passenden MIME-Part über die IMAP-BODYSTRUCTURE und bekommt ein Timeout, damit der Download nicht dauerhaft lädt.
2026-05-23 21:00:28 +02:00
d45cefbc20 E-Mail Anhang-Download über API-Client laden
KI-AGENT: Der Attachment-Download nutzt jetzt den zentralen API-Client mit apiBase, damit keine Frontend-HTML-Seite mehr als PDF gespeichert wird.
2026-05-23 20:54:29 +02:00
4fd2eb9c40 E-Mail PDF-Downloads als Binärdaten stabilisieren
KI-AGENT: Attachment-Downloads werden im Backend explizit als Buffer mit Content-Length ausgeliefert und im Frontend aus einem ArrayBuffer als Blob erzeugt, damit PDF-Anhänge unverändert gespeichert werden.
2026-05-23 20:52:22 +02:00
8697810127 E-Mail Anhang-Downloads stabilisieren
KI-AGENT: Lädt Anhänge nun authentifiziert als Blob herunter und macht die Backend-Zuordnung von Anhängen zur Originalmail robuster, inklusive Positions-Fallback.
2026-05-23 20:42:56 +02:00
358cd906ae E-Mail Aktionen und Anhang-Download ergänzen
KI-AGENT: Ergänzt IMAP-basierte Aktionen zum Verschieben, Archivieren und Löschen von E-Mails sowie den Download von Anhängen aus der Originalmail. Die E-Mail Oberfläche bietet dafür Zielordner-Auswahl, Aktionsbuttons und Anhang-Downloads.
2026-05-23 20:38:01 +02:00
be4a5caaec E-Mail Anzeige für Ordner erhalten
KI-AGENT: Behält den ausgewählten E-Mail Ordner beim Aktualisieren bei und synchronisiert leere Nicht-Posteingang-Ordner beim ersten Öffnen automatisch, damit deren Nachrichten angezeigt werden.
2026-05-23 20:28:42 +02:00
f2adc21fea E-Mail Ordner einklappbar machen
KI-AGENT: Ergänzt auf- und zuklappbare IMAP-Ordner in der E-Mail Übersicht. Elternordner bleiben initial sichtbar, ausgewählte Unterordner öffnen automatisch ihre übergeordneten Ordner.
2026-05-23 20:15:43 +02:00
7239ad92e4 E-Mail Lesestatus und Ordnerhierarchie synchronisieren
KI-AGENT: Synchronisiert Gelesen/Ungelesen mit IMAP, gleicht vorhandene Nachrichten-Flags beim Sync ab und zeigt verschachtelte IMAP-Ordner unter ihren Elternordnern an.
2026-05-23 20:13:35 +02:00
347319aee3 E-Mail Oberfläche im Outlook-Stil ergänzen
KI-AGENT: Ergänzt eine dreispaltige E-Mail Arbeitsansicht mit Konto- und Ordnerliste, Nachrichtenliste, Lesebereich, Suche, Aktualisierung und Composer-Verknüpfung. Die Navigation zeigt nun auf die neue E-Mail Übersicht.
2026-05-23 20:07:54 +02:00
21e2bc2755 E-Mail Cache und Konto-Synchronisation vorbereiten
KI-AGENT: Ergänzt Tabellen für lokalen E-Mail-Cache, IMAP-Sync-Service und Inbox-API. Überarbeitet außerdem die E-Mail-Konto-Seiten mit sicherer Passwortbehandlung und manuellem Sync.
2026-05-23 20:00:05 +02:00
c699d2ade8 KI-AGENT: Matrix Push Worker Rate Limit entschärfen 2026-05-22 21:35:33 +02:00
51e0ae95b1 KI-AGENT: Matrix Push Worker Diagnose ergänzen 2026-05-22 21:32:47 +02:00
45ca4f7327 KI-AGENT: Matrix Service User automatisch in Räume aufnehmen 2026-05-22 21:29:31 +02:00
38ccdd058b KI-AGENT: Fehlende Matrix Medienvorschauen nicht wiederholt laden 2026-05-22 21:24:17 +02:00
7f47821a7f KI-AGENT: Matrix Push Worker für ungelesene Nachrichten ergänzen 2026-05-22 21:21:08 +02:00
f150cfd740 KI-AGENT: Ungelesene Chat Badges in Mobile App anzeigen 2026-05-22 18:51:33 +02:00
00da371dfb KI-AGENT: Direktchat Push Empfänger korrigieren 2026-05-22 18:47:17 +02:00
c56fb6b571 KI-AGENT: Matrix Sync Push für eingehende Nachrichten ergänzen 2026-05-22 18:39:15 +02:00
0328a4586a KI-AGENT: Push Test im Admin Dashboard ergänzen 2026-05-22 18:32:15 +02:00
d73209a150 KI-AGENT: APNs Private Key im Push Server normalisieren 2026-05-22 18:28:52 +02:00
4bcc2152ab KI-AGENT: Mobile Build fünf für TestFlight vorbereiten 2026-05-22 18:23:46 +02:00
ab4055f2a5 KI-AGENT: Leere JSON Requests in der Mobile App korrigieren 2026-05-22 18:18:09 +02:00
d6582dd767 KI-AGENT: Mobile Build für TestFlight vorbereiten 2026-05-22 18:13:04 +02:00
3d5bec4ef8 KI-AGENT: Push-Server Env Datei fest laden 2026-05-22 18:01:35 +02:00
5963a9280c KI-AGENT: Seafile aus Standard-Stack entfernen 2026-05-22 17:54:25 +02:00
cacfce4d15 KI-AGENT: Mobile Push Registrierung anbinden 2026-05-22 17:34:52 +02:00
5400fd7ad5 KI-AGENT: Seafile Nebenpfade auf Hauptdomain routen 2026-05-22 17:30:24 +02:00
0d0dc33e84 KI-AGENT: Push-Server Migrationen stabilisieren 2026-05-22 17:22:01 +02:00
0e2e4a36be KI-AGENT: Seafile Subpfad Routing korrigieren 2026-05-22 17:21:28 +02:00
5403418c42 KI-AGENT: Frontend Route im Selfhost-Stack niedriger priorisieren 2026-05-22 17:16:24 +02:00
ab0f892bc1 KI-AGENT: Seafile Route vor Frontend priorisieren 2026-05-22 17:13:38 +02:00
69874742f8 KI-AGENT: Seafile unter Hauptdomain bereitstellen 2026-05-22 17:11:39 +02:00
736f7bba88 KI-AGENT: Nuxt UI Styles im Push Admin laden 2026-05-22 17:10:24 +02:00
ff4328f264 KI-AGENT: Matrix Element Config im Selfhost-Stack schreibbar machen 2026-05-22 17:07:45 +02:00
71f5763f7b KI-AGENT: Seafile Redis Secret URL-sicher erzeugen 2026-05-22 17:07:06 +02:00
5a4de421ce KI-AGENT: Zentralen Push-Server Stack ergänzen 2026-05-22 16:53:27 +02:00
19bab852de KI-AGENT: Seafile in Selfhost-Stack integrieren 2026-05-22 16:34:29 +02:00
8a2429827c KI-AGENT: Anrufbuttons und Telefoniejournal erweitern 2026-05-22 16:14:07 +02:00
3594dc69e8 KI-AGENT: Mobile App-Icons für Store vorbereiten 2026-05-22 16:13:26 +02:00
25e0c5389c Dokumentiere zentralen Push-Server
KI-AGENT: Entwirft den zentralen Push-Server für Selfhost-Instanzen, beschreibt API, Sicherheit, Datenschutz und Migrationspfad.
2026-05-22 15:55:59 +02:00
520052e71a KI-AGENT: Telefonie Nebenstellen in Einstellungen integrieren 2026-05-22 15:55:06 +02:00
da9cad1513 KI-AGENT: Scrollbereich der Firmeneinstellungen korrigieren 2026-05-22 15:38:27 +02:00
b44c8d453a KI-AGENT: Nebenstellen und Standardroute für Telefonie ergänzen 2026-05-22 15:24:10 +02:00
bbbdc4d2ae KI-AGENT: Website im Impressum aktualisiert 2026-05-22 15:11:58 +02:00
b15d98f6e9 KI-AGENT: Datenschutzerklärung und Anschrift aktualisiert 2026-05-22 15:10:01 +02:00
f36cbcc207 KI-AGENT: Checkbox im Kontaktformular ausgerichtet 2026-05-22 15:07:29 +02:00
76764eb4c3 KI-AGENT: Kontaktformular und Datenschutzseite ergänzt 2026-05-22 15:03:56 +02:00
0bd0120ec2 KI-AGENT: Zielgruppen-Breite auf Startseite angeglichen 2026-05-21 22:25:20 +02:00
266c07d820 KI-AGENT: Asterisk-Stand ohne FreePBX versionieren 2026-05-21 22:25:01 +02:00
cc34acac3e KI-AGENT: Zielgruppen-Seite für Webseite ergänzt 2026-05-21 22:21:48 +02:00
31b8378b87 KI-AGENT: WebRTC-Codecs für Easybell kompatibel setzen 2026-05-21 22:01:13 +02:00
33ff46744f KI-AGENT: Mehr-Details-Buttons bündig ausgerichtet 2026-05-21 21:57:08 +02:00
c44d8e172d KI-AGENT: LiveKit aus Matrix-Übersicht entfernt 2026-05-21 21:54:08 +02:00
bddb326e18 KI-AGENT: Funktionskacheln um Details erweitert 2026-05-21 21:50:25 +02:00
81cecad668 KI-AGENT: Matrix-Föderation auf Webseite ergänzt 2026-05-21 21:40:58 +02:00
7950315291 KI-AGENT: Matrix-Abschnitt nach Selfhost ausgerichtet 2026-05-21 21:36:06 +02:00
9da30ac2e8 KI-AGENT: VPS-Asterisk Dev-Anbindung ergänzen 2026-05-21 21:34:12 +02:00
fb1ccf91b9 KI-AGENT: Matrix-Stack auf Webseite ergänzt 2026-05-21 21:31:04 +02:00
42bed16e25 KI-AGENT: FreePBX Diagnoseprofil ergänzen 2026-05-21 19:01:31 +02:00
30cbc18b3a KI-AGENT: Easybell Wählstring korrigieren 2026-05-21 18:39:33 +02:00
b6705e84a7 KI-AGENT: Easybell Trunk Vorlage korrigieren 2026-05-21 18:10:28 +02:00
d26fe6dcef KI-AGENT: Easybell SIP-Trunk integrieren 2026-05-21 17:54:58 +02:00
63bf57e720 KI-AGENT: Asterisk Transport-Include korrigieren 2026-05-21 17:44:59 +02:00
1240ffd03b KI-AGENT: FEDEO Asterisk-Trunk beim Neustart bewahren 2026-05-21 17:16:07 +02:00
7a893dfdcb KI-AGENT: NAT Einstellungen für Telekom-Trunk ergänzen 2026-05-21 17:09:23 +02:00
beb91bf5c3 KI-AGENT: Asterisk PJSIP Reload für Trunk korrigieren 2026-05-21 17:00:00 +02:00
9bdd725691 KI-AGENT: Lokale Asterisk-AMI Verbindung korrigieren 2026-05-21 16:53:57 +02:00
821a5f85de KI-AGENT: Lokale Asterisk-Trunk-Dateien teilen 2026-05-21 16:50:44 +02:00
b667a856d4 KI-AGENT: Asterisk-Trunk aus FEDEO anwenden 2026-05-21 16:41:47 +02:00
f6fb607008 KI-AGENT: Telefonie-Trunk in Firmeneinstellungen verschieben 2026-05-21 16:19:56 +02:00
ee6c2d7420 KI-AGENT: Telekom Telefonie an Asterisk anbinden 2026-05-21 16:04:48 +02:00
9e7b5bc0b9 KI-AGENT: Anrufhistorie für Telefonie ergänzen 2026-05-21 15:54:24 +02:00
ba12c46c88 KI-AGENT: Telefonie und Setup trennen 2026-05-21 13:50:25 +02:00
d99cddf5b5 KI-AGENT: Anrufsignalisierung im Softphone ergänzen 2026-05-21 13:38:01 +02:00
df32bf516b KI-AGENT: Telefonieseite scrollbar machen 2026-05-21 13:35:15 +02:00
151f605eb0 KI-AGENT: Asterisk From-Domain für WebRTC setzen 2026-05-21 13:30:53 +02:00
4347a0858d KI-AGENT: SIP-Qualify für Browser-Nebenstellen deaktivieren 2026-05-21 13:28:07 +02:00
8196f8a955 KI-AGENT: SIP-Eingangssignalisierung prüfbarer machen 2026-05-21 12:11:01 +02:00
e9bfa3dc1c KI-AGENT: Eingehende SIP-Anrufe sichtbarer machen 2026-05-21 11:44:20 +02:00
88006be691 KI-AGENT: SIP-Registrierung der Testnebenstellen reparieren 2026-05-20 22:56:33 +02:00
fe23742912 KI-AGENT: Lokale SIP-Nebenstellen erreichbar machen 2026-05-20 22:50:14 +02:00
6abc0dd772 KI-AGENT: SIP-Softphone für lokale Telefonie ergänzen 2026-05-20 22:33:47 +02:00
655a78392b KI-AGENT: Asterisk-Statusprüfung robuster machen 2026-05-20 22:24:13 +02:00
10f03e151d KI-AGENT: Lokalen Asterisk-Teststack ergänzen 2026-05-20 22:18:58 +02:00
4b85ea3d2d KI-AGENT: Lege Selfhost Compose als Standarddatei ab
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 11s
Build and Push Docker Images / build-frontend (push) Successful in 11s
Build and Push Docker Images / build-website (push) Successful in 10s
Build and Push Docker Images / build-docs (push) Successful in 10s
2026-05-20 21:27:41 +02:00
8bed6e2984 KI-AGENT: Lade Selfhost Setup ohne Repo Checkout
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 11s
Build and Push Docker Images / build-frontend (push) Successful in 11s
Build and Push Docker Images / build-website (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-20 21:17:12 +02:00
9c1d3bc04c KI-AGENT: Selfhost Setup für Node Exporter ergänzen
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 53s
Build and Push Docker Images / build-website (push) Successful in 20s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-20 21:03:12 +02:00
8df587f9e2 KI-AGENT: Systemstatus und Node Exporter ergänzen 2026-05-20 20:41:48 +02:00
3796bc2953 KI-AGENT: Mobile Matrix-Kommunikation vollständig integrieren 2026-05-20 20:38:48 +02:00
2278dfa714 KI-AGENT: Call UX im Chat verbessern 2026-05-20 20:32:02 +02:00
1a5c69fcfb KI-AGENT: Live-Sync und Nachrichtenaktionen im Chat ergänzen 2026-05-20 20:27:38 +02:00
a671ae392d KI-AGENT: Mitgliederverwaltung und Suche im Chat umsetzen 2026-05-20 20:21:18 +02:00
4c58d175a0 KI-AGENT: Chat Anhänge und Nachrichteninteraktionen abrunden 2026-05-20 20:12:02 +02:00
bc655f0e06 KI-AGENT: Mobile Version auf 2.0.0 anheben
- App-Version auf 2.0.0 gesetzt
- iOS Buildnummer auf 3 gesetzt
- Package-Lock auf neue Mobile-Version aktualisiert
2026-05-20 20:09:13 +02:00
22bcf01fa8 KI-AGENT: Mobile Bundle-ID für TestFlight anpassen
- iOS Bundle Identifier und Android Package auf software.federspiel.fedeo gesetzt
- TestFlight-Dokumentation aktualisiert
- Vertriebs-Screenshots des Mobile-Dashboards ergänzt
2026-05-20 20:05:21 +02:00
bf8a3386d7 KI-AGENT: Mobile TestFlight Build vorbereiten
- EAS-Profil und Scripts für TestFlight ergänzt
- Node 22 für Mobile-Builds festgelegt und README aktualisiert
- Expo-SDK-Abhängigkeiten für expo-doctor angeglichen
2026-05-20 19:44:24 +02:00
d182231448 KI-AGENT: Selfhost-Codeblock zentriert 2026-05-19 22:41:11 +02:00
0a32ae77cd KI-AGENT: Selfhost-Codeblock schmaler ausgerichtet 2026-05-19 22:35:52 +02:00
98c95483d8 KI-AGENT: Selfhost-Script als Codeblock eingebaut 2026-05-19 22:32:21 +02:00
bcde1da84f KI-AGENT: Zeige absoluten Compose Pfad im Selfhost Setup 2026-05-19 22:19:32 +02:00
6157d7e27d KI-AGENT: Nutze Terminaleingabe im Selfhost Installer
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 11s
Build and Push Docker Images / build-frontend (push) Successful in 10s
Build and Push Docker Images / build-website (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-19 22:14:51 +02:00
0cfa6a691b KI-AGENT: Ergänze Selfhost Curl Installer
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 11s
Build and Push Docker Images / build-frontend (push) Successful in 11s
Build and Push Docker Images / build-website (push) Successful in 11s
Build and Push Docker Images / build-docs (push) Successful in 11s
2026-05-19 22:06:13 +02:00
14470da7dc KI-AGENT: Webseite in Docker-Workflow aufgenommen
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 10s
Build and Push Docker Images / build-website (push) Successful in 26s
Build and Push Docker Images / build-docs (push) Successful in 10s
2026-05-19 22:01:54 +02:00
85ac33c334 KI-AGENT: Neue Logo-Dateien in Webseite verwendet 2026-05-19 21:58:47 +02:00
9e38a488c8 KI-AGENT: Impressum auf Federspiel Technology aktualisiert 2026-05-19 21:49:56 +02:00
ed6283b9e1 KI-AGENT: Farbgebung der Webseite auf Grün aktualisiert 2026-05-19 21:44:09 +02:00
2d2e8552f0 KI-AGENT: Neue Nuxt-Webseite für FEDEO erstellt 2026-05-19 21:40:27 +02:00
25ed99b356 KI-AGENT: Ergänze geführtes Selfhost Setup 2026-05-19 20:45:54 +02:00
6cc7dc87ad KI-AGENT: Überspringe fehlende S3-Dateien beim Mandantenexport 2026-05-19 19:47:38 +02:00
697abc99fa KI-AGENT: Starte Matrix im Selfhost ohne Profil 2026-05-19 19:00:35 +02:00
bace26c084 KI-AGENT: Vereinfache Matrix Selfhost auf eine Domain
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 18:49:04 +02:00
274f3d5795 KI-AGENT: Ergänze Matrix im Selfhost-Stack 2026-05-19 18:40:30 +02:00
168d2fce6e KI-AGENT: StandardEntity Empty-State vereinheitlichen
Rendert die StandardEntity-Tabelle auch ohne Einträge und nutzt den gemeinsamen Empty-State statt der separaten Card.
2026-05-19 18:40:10 +02:00
6dcd8b1863 KI-AGENT: Tabellen-Empty-States ohne JSON rendern
Ersetzt ungültige UTable-Empty-Props durch einen gemeinsamen Empty-State-Slot, damit leere Tabellen keine Objekt-/JSON-Ausgabe mehr anzeigen.
2026-05-19 18:36:54 +02:00
81ce9d263d KI-AGENT: Ergänze fehlende Profil-Verfügbarkeitsmigration
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 10s
2026-05-19 18:25:52 +02:00
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
273 changed files with 53037 additions and 1458 deletions

View File

@@ -1,6 +1,6 @@
# FEDEO Selfhosting
DOMAIN=app.example.com
CONTACT_EMAIL=admin@example.com
CONTACT_EMAIL=admin@deine-domain.de
DB_NAME=fedeo
DB_USER=fedeo
@@ -37,6 +37,16 @@ S3_ACCESS_KEY=fedeo-minio
S3_SECRET_KEY=change-this-minio-password
S3_BUCKET=fedeo
# Datei-Backend. S3 bleibt aktuell der Standard; Seafile kann als externer
# Dateidienst angebunden werden, sobald der Backend-Umbau aktiviert ist.
FEDEO_FILE_BACKEND=s3
# Externer Seafile-Dienst, nicht Teil des Standard-Compose-Stacks.
SEAFILE_BASE_URL=https://files.example.com
SEAFILE_INTERNAL_URL=https://files.example.com
SEAFILE_ADMIN_EMAIL=admin@example.com
SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend
GOCARDLESS_BASE_URL=https://api.gocardless.com
@@ -53,6 +63,52 @@ OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
# Interner Prometheus Node Exporter für die Admin-Systemstatusseite.
NODE_EXPORTER_URL=http://node-exporter:9100
# Lokaler Asterisk-Test für SIP/Voice. Aktivieren, wenn das Compose-Profil
# `telephony-dev` genutzt wird.
TELEPHONY_ENABLED=false
ASTERISK_IMAGE=andrius/asterisk:20
TELEPHONY_ASTERISK_HTTP_URL=http://asterisk-dev:8088/ws
TELEPHONY_ASTERISK_WS_URL=ws://localhost:8088/ws
TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated
TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev
TELEPHONY_ASTERISK_AMI_PORT=5038
TELEPHONY_ASTERISK_AMI_USER=fedeo
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
TELEPHONY_SIP_DOMAIN=localhost
TELEPHONY_TEST_EXTENSION=1001
TELEPHONY_TEST_PASSWORD=fedeo-test-1001
TELEPHONY_TEST_EXTENSION_2=1002
TELEPHONY_TEST_PASSWORD_2=fedeo-test-1002
TELEPHONY_ECHO_EXTENSION=600
TELEPHONY_DEV_WS_PORT=8088
TELEPHONY_DEV_AMI_PORT=5038
TELEPHONY_DEV_SIP_PORT=5060
TELEPHONY_DEV_RTP_MIN_PORT=10000
TELEPHONY_DEV_RTP_MAX_PORT=10100
TELEPHONY_ASTERISK_EXTERNAL_SIGNALING_ADDRESS=
TELEPHONY_ASTERISK_EXTERNAL_MEDIA_ADDRESS=
# Externe Telefonie über Telekom/tel.t-online.de. Keine echten Zugangsdaten
# einchecken. SIP-ID ist in der Regel die Rufnummer mit Vorwahl ohne Leerzeichen
# und ohne Sonderzeichen, z. B. 0301234567. Wenn dein Anschluss noch die
# Internet-Zugangsdaten als Auth-User nutzt, kann TELEPHONY_TELEKOM_AUTH_USER
# aus Anschlusskennung + Zugangsnummer + # + Mitbenutzernummer + @t-online.de
# gebildet werden.
TELEPHONY_EXTERNAL_PROVIDER=
TELEPHONY_EXTERNAL_ENABLED=false
TELEPHONY_EXTERNAL_INBOUND_EXTENSION=1001
TELEPHONY_TELEKOM_ENABLED=false
TELEPHONY_TELEKOM_REGISTRAR=tel.t-online.de
TELEPHONY_TELEKOM_SIP_USER=
TELEPHONY_TELEKOM_AUTH_USER=
TELEPHONY_TELEKOM_PASSWORD=
TELEPHONY_TELEKOM_CALLER_ID=
TELEPHONY_TELEKOM_INBOUND_EXTENSION=1001
TELEPHONY_TELEKOM_OUTBOUND_PREFIX=0
# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
@@ -61,16 +117,15 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
FEDEO_BOOTSTRAP_MATRIX=true
# 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.
# Diese Werte werden von docker-compose.selfhost.yml für den integrierten
# Matrix-Stack gelesen. 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_SERVER_NAME=app.example.com
MATRIX_POSTGRES_DB=synapse
MATRIX_POSTGRES_USER=synapse
@@ -81,6 +136,15 @@ MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
LIVEKIT_KEY=fedeo-livekit
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
# Backend-Integration im Selfhost-Stack
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
MATRIX_RTC_HOST=app.example.com
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
# Lokale Matrix-Entwicklung
MATRIX_DEV_SYNAPSE_PORT=8008
MATRIX_DEV_ELEMENT_PORT=8080
@@ -94,10 +158,9 @@ 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
# Lokale Backend-Integration gegen den Matrix-Entwicklungsstack
# 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
# 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 & Docs by @${{ github.actor }}
run-name: Build Backend, Frontend, Website & Docs by @${{ github.actor }}
on: [push]
@@ -135,3 +135,35 @@ jobs:
push: true
tags: ${{ steps.meta-docs.outputs.tags }}
labels: ${{ steps.meta-docs.outputs.labels }}
build-website:
#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 Website
id: meta-website
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/website
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Website
uses: docker/build-push-action@v4
with:
context: ./website
push: true
tags: ${{ steps.meta-website.outputs.tags }}
labels: ${{ steps.meta-website.outputs.labels }}

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
.env
node_modules/
.nuxt/
.output/
# Lokale Runtime-Daten und generierte Konfigurationen
matrix/postgres/

118
README.md
View File

@@ -89,23 +89,25 @@ Wenn du MinIO verwendest, setze zusatzlich:
## Deploy-Struktur
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Selfhost-Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
Deploye den Stack in einem eigenen Betriebsverzeichnis. Der Selfhost-Installer lädt dafür nur die benötigten Betriebsdateien und klont nicht das komplette Repository.
Beispiel:
Beispiel für die manuelle Vorbereitung:
```bash
git clone <DEIN-REPO-URL> /opt/fedeo
cd /opt/fedeo
mkdir -p /opt/fedeo/scripts
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
```
Die Verzeichnisstruktur sollte dann mindestens so aussehen:
```text
/opt/fedeo/
docker-compose.selfhost.yml
docker-compose.yml
.env
backend/
frontend/
scripts/
traefik/
letsencrypt/
logs/
@@ -130,7 +132,41 @@ Als Startpunkt kannst du die Beispielumgebung kopieren:
cp .env.example .env
```
Ersetze anschließend alle Platzhalter und passe mindestens `DOMAIN`, `CONTACT_EMAIL`, Datenbank-, Secret-, SMTP- und S3-Werte an.
Ersetze anschließend alle Platzhalter und passe mindestens `DOMAIN`, `CONTACT_EMAIL`, Datenbank-, Secret-, SMTP- und S3-Werte an. Seafile ist kein Teil des Standard-Stacks; wenn FEDEO später Seafile als File-Backend nutzen soll, zeigst du die Seafile-Variablen auf einen externen Seafile-Dienst.
Alternativ kannst du die Konfiguration geführt erzeugen lassen:
```bash
bash scripts/selfhost-setup.sh
```
Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration direkt per One-Liner vorbereiten:
```bash
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
```
Der schnelle One-Liner mit direktem Stack-Start:
```bash
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
```
Der Installer prüft Basispakete, installiert Docker auf Wunsch über das offizielle Docker-Installationsscript, lädt nur die Selfhost-Dateien nach `/opt/fedeo` und startet anschließend den geführten Setup-Assistenten.
Für den schnellen Standardpfad:
```bash
bash scripts/selfhost-setup.sh --simple
```
Für mehr Rückfragen zu SMTP, API-Schlüsseln und optionalen Diensten:
```bash
bash scripts/selfhost-setup.sh --advanced
```
Der Assistent erklärt zuerst die Selfhost-Verzeichnisstruktur, schreibt anschließend `.env`, legt persistente Verzeichnisse inklusive `traefik/letsencrypt/acme.json` an und kann den Stack optional direkt starten.
## Beispiel `.env`
@@ -138,7 +174,7 @@ 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
@@ -168,6 +204,12 @@ S3_ACCESS_KEY=fedeo-minio
S3_SECRET_KEY=change-this-minio-password
S3_BUCKET=fedeo
FEDEO_FILE_BACKEND=s3
SEAFILE_BASE_URL=https://files.example.com
SEAFILE_INTERNAL_URL=https://files.example.com
SEAFILE_ADMIN_EMAIL=admin@example.com
SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend
GOCARDLESS_BASE_URL=https://api.gocardless.com
@@ -191,13 +233,32 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
MATRIX_SERVER_NAME=app.example.com
MATRIX_POSTGRES_DB=synapse
MATRIX_POSTGRES_USER=synapse
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
MATRIX_RTC_HOST=app.example.com
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
LIVEKIT_KEY=fedeo-livekit
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
```
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.
## Docker Compose mit optionaler S3-MinIO-Option
## Docker Compose mit optionalem S3 und Matrix
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.
Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. 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.
Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist.
Der Matrix-Stack ist im Selfhost-Compose direkt enthalten. Er umfasst Synapse, eine eigene PostgreSQL-Datenbank für Synapse, Redis, `.well-known/matrix`, coturn, LiveKit, den LiveKit-JWT-Service und Element Web. Das einfache Selfhost-Setup nutzt nur `DOMAIN`: Synapse läuft unter `https://DOMAIN/_matrix`, Matrix-Well-Known unter `https://DOMAIN/.well-known/matrix`, LiveKit unter `https://DOMAIN/livekit/sfu`, der JWT-Service unter `https://DOMAIN/livekit/jwt` und Element Web unter `https://DOMAIN/element`.
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.
@@ -285,8 +346,7 @@ services:
- internal
backend:
build:
context: ./backend
image: git.federspiel.tech/flfeders/fedeo/backend:dev
container_name: fedeo-backend
restart: unless-stopped
depends_on:
@@ -335,13 +395,13 @@ 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
frontend:
build:
context: ./frontend
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
container_name: fedeo-frontend
restart: unless-stopped
depends_on:
@@ -356,13 +416,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
```
@@ -391,22 +454,23 @@ Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit M
Im Deploy-Verzeichnis:
```bash
docker compose -f docker-compose.selfhost.yml build
docker compose -f docker-compose.selfhost.yml up -d
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
```
Synapse erzeugt `matrix/synapse/homeserver.yaml` beim ersten Start automatisch und aktualisiert die für FEDEO relevanten Werte aus der `.env`. `MATRIX_REGISTRATION_SHARED_SECRET` muss in der `.env` gesetzt und geheim bleiben, weil FEDEO damit Matrix-Nutzer provisioniert.
Danach Status prufen:
```bash
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
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml logs -f traefik
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.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
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
```
## Funktionsprufung
@@ -430,14 +494,16 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
Bei neuen Versionen:
```bash
git pull
docker compose -f docker-compose.selfhost.yml build
docker compose -f docker-compose.selfhost.yml up -d
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.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.
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
## Backup-Empfehlung
@@ -445,6 +511,8 @@ Regelmassig sichern:
- `./postgres`
- `./minio` falls MinIO lokal genutzt wird
- `./matrix/postgres` falls Matrix lokal betrieben wird
- `./matrix/synapse` falls Matrix lokal betrieben wird
- `./traefik/letsencrypt/acme.json`
- deine `.env`
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management

View File

@@ -0,0 +1,6 @@
node_modules
dist
.venv-opencv
.env
*.log
*.tmp

View File

@@ -0,0 +1,14 @@
FEDEO_URL=https://fedeo.example.com
FEDEO_AGENT_TOKEN=fedeo_agent_REPLACE_ME
FEDEO_POLL_SECONDS=5
FEDEO_WORK_DIR=/tmp/fedeo-device-agent
FEDEO_SCANNER_NAME=
FEDEO_PRINTER_NAME=
FEDEO_SCAN_FORMAT=pdf
FEDEO_SCAN_RESOLUTION=300
FEDEO_SCAN_MODE=Color
FEDEO_SCAN_SOURCE=
FEDEO_SCAN_POSTPROCESS=false
FEDEO_SCAN_POSTPROCESS_PROFILE=document
FEDEO_SCAN_POSTPROCESS_PYTHON=
FEDEO_SCAN_POSTPROCESS_STRICT=false

6
agents/fedeo-device-agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
dist
node_modules
.venv-opencv
.env
*.log
*.tmp

View File

@@ -0,0 +1,45 @@
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json tsconfig.json ./
RUN npm install
COPY src ./src
RUN npm run build
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV FEDEO_WORK_DIR=/work
ENV FEDEO_SCAN_POSTPROCESS=true
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=receipt
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
WORKDIR /opt/fedeo-device-agent
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
cups-client \
libgomp1 \
python3 \
python3-pip \
python3-venv \
sane-utils \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-opencv.txt ./
RUN python3 -m venv .venv-opencv \
&& .venv-opencv/bin/python -m pip install --no-cache-dir --upgrade pip \
&& .venv-opencv/bin/python -m pip install --no-cache-dir -r requirements-opencv.txt \
&& .venv-opencv/bin/python -c "import cv2, PIL, numpy"
COPY --from=build /app/dist ./dist
COPY scripts ./scripts
COPY package.json ./
RUN mkdir -p /work
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,133 @@
# FEDEO Geräte-Agent
Der FEDEO Geräte-Agent läuft lokal auf macOS, Linux oder Raspberry Pi OS. Er holt instanzweite Scan-Aufträge von FEDEO ab, führt sie auf einem lokal angeschlossenen Scanner aus und lädt das Ergebnis wieder in FEDEO hoch.
Der Agent ist nicht an einen Mandanten gebunden. Jeder Auftrag enthält seinen Tenant selbst.
## Voraussetzungen
### macOS
```bash
brew install node sane-backends
scanimage -L
```
Drucken nutzt später das macOS-Drucksystem/CUPS:
```bash
lpstat -p
```
### Linux und Raspberry Pi OS
```bash
sudo apt update
sudo apt install -y nodejs npm sane-utils cups
scanimage -L
lpstat -p
```
## Konfiguration
```bash
cp .env.example .env
nano .env
```
Wichtige Werte:
```env
FEDEO_URL=https://deine-fedeo-instanz
FEDEO_AGENT_TOKEN=fedeo_agent_...
FEDEO_SCANNER_NAME=
FEDEO_POLL_SECONDS=5
```
Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanner.
## Entwicklung
```bash
npm install
npm run dev
```
## OpenCV-Nachbearbeitung
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
```bash
npm run setup:opencv
```
Konfiguration:
```env
FEDEO_SCAN_POSTPROCESS=true
FEDEO_SCAN_POSTPROCESS_PROFILE=receipt
FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
FEDEO_SCAN_POSTPROCESS_STRICT=false
```
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, lädt der Agent den Rohscan hoch, statt den Auftrag komplett fehlschlagen zu lassen.
Profile:
- `receipt`: Bons und schmale Belege werden bevorzugt hochkant zugeschnitten und kontrastiert.
- `document`: allgemeine Dokumente mit Farberhalt und moderater Verbesserung.
- `raw`: Zuschnitt/Entzerrung ohne starke Kontrastkorrektur.
## Container-Betrieb
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
```bash
cp .env.example .env
nano .env
docker compose -f docker-compose.example.yml up --build
```
Wenn FEDEO lokal auf dem Docker-Host läuft, verwende im Container nicht `localhost`, sondern:
```env
FEDEO_URL=http://host.docker.internal:3100
```
Scanner im Container prüfen:
```bash
docker compose -f docker-compose.example.yml run --rm fedeo-device-agent scanimage -L
```
Wenn der Scanner nicht sichtbar ist, hilft je nach Gerät/Host manchmal `privileged: true` im Compose-Beispiel. Auf macOS ist Docker dafür nur eingeschränkt geeignet, weil Docker Desktop USB-Scanner normalerweise nicht direkt an Linux-Container durchreichen kann. Für macOS bleibt deshalb der native Agent oder später eine signierte App der bessere Weg.
## Build
```bash
npm run build
npm start
```
## FEDEO-Endpunkte
Der Agent nutzt:
- `POST /instance-agent/heartbeat`
- `GET /instance-agent/scan-jobs/next`
- `POST /instance-agent/scan-jobs/:id/status`
- `POST /instance-agent/scan-jobs/:id/upload`
## macOS Autostart
Die Vorlage liegt unter `system/macos/com.fedeo.device-agent.plist`. Nach Anpassung der Pfade kann sie als LaunchAgent installiert werden:
```bash
mkdir -p ~/Library/LaunchAgents
cp system/macos/com.fedeo.device-agent.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.fedeo.device-agent.plist
```
## Linux Autostart
Die Vorlage liegt unter `system/linux/fedeo-device-agent.service`.

View File

@@ -0,0 +1,28 @@
services:
fedeo-device-agent:
build:
context: .
image: fedeo-device-agent:local
container_name: fedeo-device-agent
restart: unless-stopped
env_file:
- .env
environment:
FEDEO_WORK_DIR: /work
FEDEO_SCAN_POSTPROCESS: "true"
FEDEO_SCAN_POSTPROCESS_PROFILE: receipt
FEDEO_SCAN_POSTPROCESS_PYTHON: /opt/fedeo-device-agent/.venv-opencv/bin/python
FEDEO_SCAN_POSTPROCESS_STRICT: "false"
volumes:
- fedeo-device-agent-work:/work
# Optional fuer CUPS-Druck ueber den Host:
# - /var/run/cups/cups.sock:/var/run/cups/cups.sock
extra_hosts:
- "host.docker.internal:host-gateway"
devices:
- /dev/bus/usb:/dev/bus/usb
# Falls SANE den Scanner trotz devices-Mapping nicht sieht, testweise aktivieren:
# privileged: true
volumes:
fedeo-device-agent-work:

View File

@@ -0,0 +1,26 @@
{
"name": "@fedeo/device-agent",
"version": "0.1.0",
"private": true,
"description": "Lokaler FEDEO Druck- und Scan-Agent für macOS, Linux und Raspberry Pi OS.",
"type": "module",
"main": "dist/main.js",
"bin": {
"fedeo-device-agent": "dist/main.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx src/main.ts",
"start": "node dist/main.js",
"setup:opencv": "sh scripts/setup-opencv.sh"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^24.3.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"engines": {
"node": ">=20"
}
}

View File

@@ -0,0 +1,3 @@
opencv-python-headless>=4.9
Pillow>=10.0
numpy>=1.26

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
import argparse
import math
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
def order_points(points):
rect = np.zeros((4, 2), dtype="float32")
point_sum = points.sum(axis=1)
point_diff = np.diff(points, axis=1)
rect[0] = points[np.argmin(point_sum)]
rect[2] = points[np.argmax(point_sum)]
rect[1] = points[np.argmin(point_diff)]
rect[3] = points[np.argmax(point_diff)]
return rect
def four_point_transform(image, points):
rect = order_points(points)
top_left, top_right, bottom_right, bottom_left = rect
width_a = np.linalg.norm(bottom_right - bottom_left)
width_b = np.linalg.norm(top_right - top_left)
max_width = int(max(width_a, width_b))
height_a = np.linalg.norm(top_right - bottom_right)
height_b = np.linalg.norm(top_left - bottom_left)
max_height = int(max(height_a, height_b))
destination = np.array([
[0, 0],
[max_width - 1, 0],
[max_width - 1, max_height - 1],
[0, max_height - 1],
], dtype="float32")
matrix = cv2.getPerspectiveTransform(rect, destination)
return cv2.warpPerspective(image, matrix, (max_width, max_height), borderValue=(255, 255, 255))
def rotate_bound(image, angle):
height, width = image.shape[:2]
center = (width / 2, height / 2)
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
cos = abs(matrix[0, 0])
sin = abs(matrix[0, 1])
new_width = int((height * sin) + (width * cos))
new_height = int((height * cos) + (width * sin))
matrix[0, 2] += (new_width / 2) - center[0]
matrix[1, 2] += (new_height / 2) - center[1]
return cv2.warpAffine(image, matrix, (new_width, new_height), borderValue=(255, 255, 255))
def deskew_by_text_angle(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
inverted = cv2.bitwise_not(gray)
threshold = cv2.threshold(inverted, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
coordinates = np.column_stack(np.where(threshold > 0))
if len(coordinates) < 500:
return image
angle = cv2.minAreaRect(coordinates)[-1]
if angle < -45:
angle = -(90 + angle)
else:
angle = -angle
if abs(angle) < 0.2 or abs(angle) > 8:
return image
return rotate_bound(image, angle)
def find_document_contour(image, profile):
ratio = image.shape[0] / 900.0
resized = cv2.resize(image, (int(image.shape[1] / ratio), 900))
gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(gray, 45, 140)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7))
edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contours = sorted(contours, key=cv2.contourArea, reverse=True)[:8]
min_area = resized.shape[0] * resized.shape[1] * (0.03 if profile == "receipt" else 0.12)
for contour in contours:
if cv2.contourArea(contour) < min_area:
continue
perimeter = cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, 0.025 * perimeter, True)
if len(approx) == 4:
return approx.reshape(4, 2) * ratio
return None
def trim_light_border(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
mask = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY_INV)[1]
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return image
contour = max(contours, key=cv2.contourArea)
if cv2.contourArea(contour) < image.shape[0] * image.shape[1] * 0.02:
return image
x, y, width, height = cv2.boundingRect(contour)
padding = max(12, int(min(width, height) * 0.025))
x = max(0, x - padding)
y = max(0, y - padding)
width = min(image.shape[1] - x, width + padding * 2)
height = min(image.shape[0] - y, height + padding * 2)
return image[y:y + height, x:x + width]
def enhance_receipt(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
gray = clahe.apply(gray)
gray = cv2.fastNlMeansDenoising(gray, None, 8, 7, 21)
gray = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX)
return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
def enhance_document(image):
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
l_channel, a_channel, b_channel = cv2.split(lab)
clahe = cv2.createCLAHE(clipLimit=1.6, tileGridSize=(8, 8))
l_channel = clahe.apply(l_channel)
return cv2.cvtColor(cv2.merge((l_channel, a_channel, b_channel)), cv2.COLOR_LAB2BGR)
def auto_rotate_profile(image, profile):
height, width = image.shape[:2]
if profile == "receipt" and width > height:
return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
return image
def postprocess(input_path, output_path, profile):
image = cv2.imread(str(input_path), cv2.IMREAD_COLOR)
if image is None:
raise RuntimeError(f"OpenCV konnte {input_path} nicht lesen")
contour = find_document_contour(image, profile)
if contour is not None:
processed = four_point_transform(image, contour.astype("float32"))
else:
processed = trim_light_border(image)
processed = deskew_by_text_angle(processed)
processed = trim_light_border(processed)
processed = auto_rotate_profile(processed, profile)
if profile == "receipt":
processed = enhance_receipt(processed)
elif profile != "raw":
processed = enhance_document(processed)
save_output(processed, output_path)
def save_output(image, output_path):
suffix = output_path.suffix.lower()
if suffix == ".pdf":
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(rgb)
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
pil_image.save(output_path, "PDF", resolution=300.0)
return
if suffix in {".jpg", ".jpeg"}:
cv2.imwrite(str(output_path), image, [cv2.IMWRITE_JPEG_QUALITY, 92])
return
if suffix == ".png":
cv2.imwrite(str(output_path), image, [cv2.IMWRITE_PNG_COMPRESSION, 3])
return
if suffix in {".tif", ".tiff"}:
cv2.imwrite(str(output_path), image)
return
raise RuntimeError(f"Nicht unterstütztes Ausgabeformat: {suffix}")
def main():
parser = argparse.ArgumentParser(description="FEDEO Scan-Nachbearbeitung mit OpenCV")
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--profile", default="document", choices=["document", "receipt", "raw"])
args = parser.parse_args()
postprocess(Path(args.input), Path(args.output), args.profile)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
AGENT_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
VENV_DIR="${FEDEO_SCAN_POSTPROCESS_VENV:-$AGENT_DIR/.venv-opencv}"
PYTHON_BIN="${PYTHON:-python3}"
echo "FEDEO OpenCV-Umgebung wird vorbereitet"
echo "Agent: $AGENT_DIR"
echo "Venv: $VENV_DIR"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "Fehler: $PYTHON_BIN wurde nicht gefunden." >&2
exit 1
fi
"$PYTHON_BIN" -m venv "$VENV_DIR"
"$VENV_DIR/bin/python" -m pip install --upgrade pip
"$VENV_DIR/bin/python" -m pip install -r "$AGENT_DIR/requirements-opencv.txt"
"$VENV_DIR/bin/python" -c "import cv2, PIL, numpy; print('OpenCV OK')"
echo
echo "Fertig. Verwende in .env:"
echo "FEDEO_SCAN_POSTPROCESS=true"
echo "FEDEO_SCAN_POSTPROCESS_PYTHON=$VENV_DIR/bin/python"

View File

@@ -0,0 +1,67 @@
import { readFile } from "node:fs/promises"
import { basename } from "node:path"
import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js"
export class FedeoApi {
constructor(private readonly config: AgentConfig) {}
private url(path: string) {
return `${this.config.fedeoUrl}${path}`
}
private headers(extra?: HeadersInit): HeadersInit {
return {
"X-Agent-Token": this.config.agentToken,
...extra,
}
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(this.url(path), {
...init,
headers: this.headers(init.headers),
})
if (!response.ok) {
const body = await response.text().catch(() => "")
throw new Error(`${init.method || "GET"} ${path} fehlgeschlagen: ${response.status} ${body}`)
}
return await response.json() as T
}
heartbeat(payload: AgentHeartbeat) {
return this.request<{ status: string; pendingScanJobs: number }>("/instance-agent/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
}
nextScanJob() {
return this.request<NextScanJobResponse>("/instance-agent/scan-jobs/next")
}
updateScanJobStatus(jobId: string, status: "running" | "failed" | "canceled", message?: string) {
return this.request(`/instance-agent/scan-jobs/${jobId}/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status, message }),
})
}
async uploadScan(jobId: string, result: ScanResult) {
const form = new FormData()
const fileBuffer = await readFile(result.path)
const file = new File([fileBuffer], result.filename || basename(result.path), {
type: result.mimeType,
})
form.append("file", file)
return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, {
method: "POST",
body: form,
})
}
}

View File

@@ -0,0 +1,48 @@
import { spawn } from "node:child_process"
export type CommandResult = {
stdout: string
stderr: string
code: number
}
export const commandExists = (command: string) =>
new Promise<boolean>((resolve) => {
const child = spawn("sh", ["-lc", `command -v ${command}`])
child.on("error", () => resolve(false))
child.on("close", (code) => resolve(code === 0))
})
export const runCommand = (
command: string,
args: string[],
options: { timeoutMs?: number } = {}
) =>
new Promise<CommandResult>((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
const timeout = options.timeoutMs
? setTimeout(() => {
child.kill("SIGTERM")
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
}, options.timeoutMs)
: null
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
child.on("error", reject)
child.on("close", (code) => {
if (timeout) clearTimeout(timeout)
resolve({
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
code: code ?? 0,
})
})
})

View File

@@ -0,0 +1,68 @@
import path from "node:path"
import os from "node:os"
import { existsSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { AgentConfig } from "./types.js"
import { loadDotEnv } from "./env.js"
const currentFile = fileURLToPath(import.meta.url)
const agentRoot = path.resolve(path.dirname(currentFile), "..")
const optional = (value: string | undefined) => {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
const numberFromEnv = (value: string | undefined, fallback: number) => {
if (!value) return fallback
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
const scanFormatFromEnv = (value: string | undefined): AgentConfig["scanFormat"] => {
if (value === "png" || value === "tiff" || value === "pdf") return value
return "pdf"
}
const booleanFromEnv = (value: string | undefined, fallback: boolean) => {
if (!value) return fallback
return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase())
}
const postprocessProfileFromEnv = (value: string | undefined): AgentConfig["postprocessProfile"] => {
if (value === "document" || value === "receipt" || value === "raw") return value
return "document"
}
const defaultPostprocessPython = () => {
const localVenvPython = path.join(agentRoot, ".venv-opencv", "bin", "python")
return existsSync(localVenvPython) ? localVenvPython : "python3"
}
export const loadConfig = (): AgentConfig => {
loadDotEnv(process.env.FEDEO_AGENT_ENV || ".env")
const fedeoUrl = optional(process.env.FEDEO_URL)
const agentToken = optional(process.env.FEDEO_AGENT_TOKEN)
if (!fedeoUrl) throw new Error("FEDEO_URL fehlt")
if (!agentToken) throw new Error("FEDEO_AGENT_TOKEN fehlt")
return {
fedeoUrl: fedeoUrl.replace(/\/+$/, ""),
agentToken,
pollSeconds: numberFromEnv(process.env.FEDEO_POLL_SECONDS, 5),
workDir: optional(process.env.FEDEO_WORK_DIR) || path.join(os.tmpdir(), "fedeo-device-agent"),
scannerName: optional(process.env.FEDEO_SCANNER_NAME),
printerName: optional(process.env.FEDEO_PRINTER_NAME),
scanFormat: scanFormatFromEnv(process.env.FEDEO_SCAN_FORMAT),
scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300),
scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color",
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
postprocessStrict: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_STRICT, false),
}
}

View File

@@ -0,0 +1,32 @@
import { readFileSync, existsSync } from "node:fs"
const parseEnvLine = (line: string) => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) return null
const separator = trimmed.indexOf("=")
if (separator === -1) return null
const key = trimmed.slice(0, separator).trim()
let value = trimmed.slice(separator + 1).trim()
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return { key, value }
}
export const loadDotEnv = (path = ".env") => {
if (!existsSync(path)) return
const content = readFileSync(path, "utf8")
for (const line of content.split(/\r?\n/)) {
const parsed = parseEnvLine(line)
if (!parsed) continue
if (process.env[parsed.key] === undefined) process.env[parsed.key] = parsed.value
}
}

View File

@@ -0,0 +1,30 @@
const timestamp = () => new Date().toISOString()
export const log = {
info(message: string, meta?: unknown) {
if (meta === undefined) {
console.log(`[${timestamp()}] INFO ${message}`)
return
}
console.log(`[${timestamp()}] INFO ${message}`, meta)
},
warn(message: string, meta?: unknown) {
if (meta === undefined) {
console.warn(`[${timestamp()}] WARN ${message}`)
return
}
console.warn(`[${timestamp()}] WARN ${message}`, meta)
},
error(message: string, meta?: unknown) {
if (meta === undefined) {
console.error(`[${timestamp()}] ERROR ${message}`)
return
}
console.error(`[${timestamp()}] ERROR ${message}`, meta)
},
}

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env node
import os from "node:os"
import { FedeoApi } from "./api.js"
import { loadConfig } from "./config.js"
import { log } from "./logger.js"
import { listPrinters } from "./print/cups.js"
import { hasSane, listScanners, runScan } from "./scan/sane.js"
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const stringifyError = (error: unknown) => {
if (error instanceof Error) return error.message
return String(error)
}
const main = async () => {
const config = loadConfig()
const api = new FedeoApi(config)
log.info("FEDEO Geräte-Agent startet", {
platform: process.platform,
workDir: config.workDir,
pollSeconds: config.pollSeconds,
})
while (true) {
try {
const scannerNames = await listScanners()
const printerNames = await listPrinters()
const scanAvailable = await hasSane()
const heartbeat = await api.heartbeat({
capabilities: {
scan: scanAvailable,
print: printerNames.length > 0,
platform: process.platform,
},
scannerNames,
printerNames,
debugInfo: {
hostname: os.hostname(),
release: os.release(),
arch: os.arch(),
node: process.version,
uptimeSeconds: Math.round(os.uptime()),
},
})
if (heartbeat.pendingScanJobs > 0) {
log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`)
}
const next = await api.nextScanJob()
if (!next.job) {
await sleep(config.pollSeconds * 1000)
continue
}
log.info("Scan-Auftrag wird ausgeführt", {
jobId: next.job.id,
tenantId: next.job.tenantId,
scannerName: next.job.scannerName || config.scannerName || "default",
})
try {
await api.updateScanJobStatus(next.job.id, "running")
const scanResult = await runScan(config, next.job)
await api.uploadScan(next.job.id, scanResult)
log.info("Scan-Auftrag abgeschlossen", {
jobId: next.job.id,
file: scanResult.filename,
})
} catch (error) {
const message = stringifyError(error)
log.error("Scan-Auftrag fehlgeschlagen", {
jobId: next.job.id,
message,
})
await api.updateScanJobStatus(next.job.id, "failed", message)
}
} catch (error) {
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))
await sleep(config.pollSeconds * 1000)
}
}
}
main().catch((error) => {
log.error("Agent konnte nicht gestartet werden", stringifyError(error))
process.exit(1)
})

View File

@@ -0,0 +1,15 @@
import { commandExists, runCommand } from "../commands.js"
export const hasCups = () => commandExists("lpstat")
export const listPrinters = async () => {
if (!await hasCups()) return []
const result = await runCommand("lpstat", ["-p"], { timeoutMs: 10_000 })
if (result.code !== 0) return []
return result.stdout
.split(/\r?\n/)
.map((line) => line.match(/^printer\s+(\S+)/)?.[1])
.filter((printer): printer is string => Boolean(printer))
}

View File

@@ -0,0 +1,66 @@
import path from "node:path"
import { fileURLToPath } from "node:url"
import { AgentConfig, ScanResult } from "../types.js"
import { commandExists, runCommand } from "../commands.js"
const currentFile = fileURLToPath(import.meta.url)
const agentRoot = path.resolve(path.dirname(currentFile), "../..")
const postprocessScript = path.join(agentRoot, "scripts/opencv_postprocess.py")
const extensionMimeTypes: Record<string, string> = {
".pdf": "application/pdf",
".png": "image/png",
".tif": "image/tiff",
".tiff": "image/tiff",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
}
const ensureOutputExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
const ext = path.extname(filename)
if (ext) return filename
return `${filename}.${format}`
}
export const hasOpenCvPostprocessRuntime = async (config: AgentConfig) => {
if (!await commandExists(config.postprocessPython)) return false
const result = await runCommand(config.postprocessPython, [
"-c",
"import cv2, PIL, numpy",
], { timeoutMs: 10_000 })
return result.code === 0
}
export const postprocessScan = async (
config: AgentConfig,
inputPath: string,
outputFilename: string,
outputFormat: AgentConfig["scanFormat"],
profile: AgentConfig["postprocessProfile"]
): Promise<ScanResult> => {
const filename = ensureOutputExtension(outputFilename, outputFormat)
const outputPath = path.join(config.workDir, filename)
const result = await runCommand(config.postprocessPython, [
postprocessScript,
"--input",
inputPath,
"--output",
outputPath,
"--profile",
profile,
], { timeoutMs: 5 * 60 * 1000 })
if (result.code !== 0) {
throw new Error(result.stderr || `OpenCV-Nachbearbeitung wurde mit Code ${result.code} beendet`)
}
const extension = path.extname(outputPath).toLowerCase()
return {
path: outputPath,
filename,
mimeType: extensionMimeTypes[extension] || "application/octet-stream",
}
}

View File

@@ -0,0 +1,149 @@
import { mkdirSync } from "node:fs"
import path from "node:path"
import { AgentConfig, ScanJob, ScanResult } from "../types.js"
import { commandExists, runCommand } from "../commands.js"
import { hasOpenCvPostprocessRuntime, postprocessScan } from "./postprocess.js"
import { log } from "../logger.js"
const mimeTypes = {
pdf: "application/pdf",
png: "image/png",
tiff: "image/tiff",
}
const stringSetting = (settings: Record<string, unknown> | undefined, key: string) => {
const value = settings?.[key]
return typeof value === "string" && value.trim() ? value.trim() : undefined
}
const numberSetting = (settings: Record<string, unknown> | undefined, key: string) => {
const value = settings?.[key]
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string" && value.trim()) {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
const booleanSetting = (settings: Record<string, unknown> | undefined, key: string, fallback: boolean) => {
const value = settings?.[key]
if (typeof value === "boolean") return value
if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase())
return fallback
}
const profileSetting = (
settings: Record<string, unknown> | undefined,
fallback: AgentConfig["postprocessProfile"]
): AgentConfig["postprocessProfile"] => {
const value = settings?.postprocessProfile
if (value === "document" || value === "receipt" || value === "raw") return value
return fallback
}
const ensureFilenameExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
const ext = path.extname(filename)
if (!ext) return `${filename}.${format}`
const expectedExt = `.${format}`
if (ext.toLowerCase() === expectedExt) return filename
return `${filename.slice(0, -ext.length)}${expectedExt}`
}
const fallbackRawResult = (scanOutputPath: string, jobId: string): ScanResult => ({
path: scanOutputPath,
filename: `${jobId}.raw.png`,
mimeType: "image/png",
})
export const hasSane = () => commandExists("scanimage")
export const listScanners = async () => {
if (!await hasSane()) return []
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 })
if (result.code !== 0) return []
return result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("device `"))
.map((line) => line.match(/device `([^']+)'/)?.[1])
.filter((device): device is string => Boolean(device))
}
export const runScan = async (config: AgentConfig, job: ScanJob): Promise<ScanResult> => {
if (!await hasSane()) {
throw new Error("scanimage ist nicht installiert oder nicht im PATH")
}
mkdirSync(config.workDir, { recursive: true })
const settings = job.settings || {}
const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat
const resolution = numberSetting(settings, "resolution") || config.scanResolution
const mode = stringSetting(settings, "mode") || config.scanMode
const source = stringSetting(settings, "source") || config.scanSource
const scannerName = job.scannerName || config.scannerName
const filename = ensureFilenameExtension(job.requestedFilename || `${job.id}.${format}`, format)
const outputPath = path.join(config.workDir, filename)
const shouldPostprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
const postprocessProfile = profileSetting(settings, config.postprocessProfile)
const scanFormat = shouldPostprocess ? "png" : format
const scanOutputPath = shouldPostprocess
? path.join(config.workDir, `${job.id}.raw.png`)
: outputPath
const args = [
"--format",
scanFormat,
"--resolution",
String(resolution),
"--mode",
mode,
"--output-file",
scanOutputPath,
]
if (source) args.push("--source", source)
if (scannerName) args.push("--device-name", scannerName)
const result = await runCommand("scanimage", args, { timeoutMs: 5 * 60 * 1000 })
if (result.code !== 0) {
throw new Error(result.stderr || `scanimage wurde mit Code ${result.code} beendet`)
}
if (shouldPostprocess) {
if (!await hasOpenCvPostprocessRuntime(config)) {
const message = "OpenCV-Nachbearbeitung ist aktiviert, aber python3 mit cv2, Pillow und numpy ist nicht verfügbar"
if (config.postprocessStrict) throw new Error(message)
log.warn(`${message}. Rohscan wird ohne Korrektur hochgeladen.`, {
jobId: job.id,
python: config.postprocessPython,
})
return fallbackRawResult(scanOutputPath, job.id)
}
try {
return await postprocessScan(config, scanOutputPath, filename, format, postprocessProfile)
} catch (error) {
if (config.postprocessStrict) throw error
log.warn("OpenCV-Nachbearbeitung fehlgeschlagen. Rohscan wird ohne Korrektur hochgeladen.", {
jobId: job.id,
error: error instanceof Error ? error.message : String(error),
})
return fallbackRawResult(scanOutputPath, job.id)
}
}
return {
path: outputPath,
filename,
mimeType: mimeTypes[format] || "application/octet-stream",
}
}

View File

@@ -0,0 +1,48 @@
export type AgentConfig = {
fedeoUrl: string
agentToken: string
pollSeconds: number
workDir: string
scannerName?: string
printerName?: string
scanFormat: "pdf" | "png" | "tiff"
scanResolution: number
scanMode: string
scanSource?: string
scanPostprocess: boolean
postprocessProfile: "document" | "receipt" | "raw"
postprocessPython: string
postprocessStrict: boolean
}
export type AgentHeartbeat = {
capabilities: {
scan: boolean
print: boolean
platform: NodeJS.Platform
}
scannerNames: string[]
printerNames: string[]
debugInfo: Record<string, unknown>
}
export type ScanJob = {
id: string
tenantId: number
agentId: string
status: string
scannerName?: string | null
requestedFilename?: string | null
settings?: Record<string, unknown>
target?: Record<string, unknown>
}
export type NextScanJobResponse = {
job: ScanJob | null
}
export type ScanResult = {
path: string
filename: string
mimeType: string
}

View File

@@ -0,0 +1,15 @@
[Unit]
Description=FEDEO Geräte-Agent
After=network-online.target
Wants=network-online.target
[Service]
EnvironmentFile=/etc/fedeo-device-agent/config.env
WorkingDirectory=/opt/fedeo-device-agent
ExecStart=/usr/bin/node /opt/fedeo-device-agent/dist/main.js
Restart=always
RestartSec=5
User=fedeo-agent
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.fedeo.device-agent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/opt/fedeo-device-agent/dist/main.js</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>FEDEO_AGENT_ENV</key>
<string>/opt/fedeo-device-agent/.env</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/fedeo-device-agent.log</string>
<key>StandardErrorPath</key>
<string>/tmp/fedeo-device-agent.err.log</string>
</dict>
</plist>

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"typeRoots": ["../../backend/node_modules/@types"],
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

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,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,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;

View File

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

View File

@@ -0,0 +1,58 @@
CREATE TABLE IF NOT EXISTS "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
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_tenant_id_tenants_id_fk'
) THEN
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;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_created_by_auth_users_id_fk'
) THEN
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;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_updated_by_auth_users_id_fk'
) THEN
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;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "communication_rooms_tenant_key_idx"
ON "communication_rooms" USING btree ("tenant_id", "key");
CREATE INDEX IF NOT EXISTS "communication_rooms_tenant_idx"
ON "communication_rooms" USING btree ("tenant_id");
CREATE INDEX IF NOT EXISTS "communication_rooms_entity_idx"
ON "communication_rooms" USING btree ("tenant_id", "entity_type", "entity_id", "entity_uuid");

View File

@@ -0,0 +1,57 @@
CREATE TABLE IF NOT EXISTS "telephony_calls" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"direction" text NOT NULL,
"status" text DEFAULT 'ringing' NOT NULL,
"local_extension" text,
"remote_number" text,
"remote_display_name" text,
"sip_call_id" text,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"answered_at" timestamp with time zone,
"ended_at" timestamp with time zone,
"duration_seconds" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_calls_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "telephony_calls"
ADD CONSTRAINT "telephony_calls_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_calls_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_calls"
ADD CONSTRAINT "telephony_calls_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_calls_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_calls"
ADD CONSTRAINT "telephony_calls_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "telephony_calls_tenant_started_idx"
ON "telephony_calls" USING btree ("tenant_id", "started_at");
CREATE INDEX IF NOT EXISTS "telephony_calls_created_by_idx"
ON "telephony_calls" USING btree ("tenant_id", "created_by");
CREATE INDEX IF NOT EXISTS "telephony_calls_sip_call_idx"
ON "telephony_calls" USING btree ("tenant_id", "sip_call_id");

View File

@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS "telephony_trunks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"provider" text DEFAULT 'telekom' NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"registrar" text DEFAULT 'tel.t-online.de' NOT NULL,
"sip_user" text,
"auth_user" text,
"password" text,
"caller_id" text,
"inbound_extension" text DEFAULT '1001' NOT NULL,
"outbound_prefix" text DEFAULT '0' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "telephony_trunks_tenant_provider_idx"
ON "telephony_trunks" USING btree ("tenant_id", "provider");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "telephony_trunks" ADD COLUMN IF NOT EXISTS "external_signaling_address" text;
ALTER TABLE "telephony_trunks" ADD COLUMN IF NOT EXISTS "external_media_address" text;
ALTER TABLE "telephony_trunks" ADD COLUMN IF NOT EXISTS "local_networks" text;

View File

@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS "telephony_extensions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"target_type" text NOT NULL,
"target_user_id" uuid,
"target_team_id" bigint,
"target_branch_id" bigint,
"extension" text NOT NULL,
"display_name" text,
"sip_username" text,
"sip_password" text,
"enabled" boolean DEFAULT true 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 "telephony_trunks"
ADD COLUMN IF NOT EXISTS "default_route_extension_id" uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_user_id_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_user_id_auth_users_id_fk"
FOREIGN KEY ("target_user_id") REFERENCES "public"."auth_users"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_team_id_teams_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_team_id_teams_id_fk"
FOREIGN KEY ("target_team_id") REFERENCES "public"."teams"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_branch_id_branches_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_branch_id_branches_id_fk"
FOREIGN KEY ("target_branch_id") REFERENCES "public"."branches"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_default_route_extension_id_telephony_extensions_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_default_route_extension_id_telephony_extensions_id_fk"
FOREIGN KEY ("default_route_extension_id") REFERENCES "public"."telephony_extensions"("id")
ON DELETE set null ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "telephony_extensions_tenant_extension_idx"
ON "telephony_extensions" USING btree ("tenant_id", "extension");
CREATE INDEX IF NOT EXISTS "telephony_extensions_tenant_target_idx"
ON "telephony_extensions" USING btree ("tenant_id", "target_type");

View File

@@ -0,0 +1,19 @@
CREATE TABLE "notification_mobile_push_devices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"local_device_id" text NOT NULL,
"central_device_id" text NOT NULL,
"platform" text NOT NULL,
"provider_token_preview" 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
);
--> statement-breakpoint
ALTER TABLE "notification_mobile_push_devices" ADD CONSTRAINT "notification_mobile_push_devices_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "notification_mobile_push_devices" ADD CONSTRAINT "notification_mobile_push_devices_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "notification_mobile_push_devices_user_device_key" ON "notification_mobile_push_devices" USING btree ("tenant_id","user_id","local_device_id");--> statement-breakpoint
CREATE UNIQUE INDEX "notification_mobile_push_devices_central_device_key" ON "notification_mobile_push_devices" USING btree ("central_device_id");

View File

@@ -0,0 +1,106 @@
CREATE TABLE "email_mailboxes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"account_id" uuid NOT NULL,
"path" text NOT NULL,
"delimiter" text,
"name" text NOT NULL,
"special_use" text,
"flags" jsonb,
"exists" integer DEFAULT 0 NOT NULL,
"unseen" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "email_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"account_id" uuid NOT NULL,
"mailbox_id" uuid NOT NULL,
"mailbox_path" text NOT NULL,
"uid" bigint NOT NULL,
"email_id" text,
"message_id" text,
"in_reply_to" text,
"thread_id" text,
"subject" text,
"from" jsonb,
"to" jsonb,
"cc" jsonb,
"bcc" jsonb,
"reply_to" jsonb,
"preview" text,
"flags" jsonb,
"seen" boolean DEFAULT false NOT NULL,
"flagged" boolean DEFAULT false NOT NULL,
"has_attachments" boolean DEFAULT false NOT NULL,
"size" bigint,
"sent_at" timestamp with time zone,
"received_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "email_message_bodies" (
"message_id" uuid PRIMARY KEY NOT NULL,
"text" text,
"html" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "email_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"message_id" uuid NOT NULL,
"filename" text,
"content_type" text,
"content_id" text,
"disposition" text,
"size" bigint,
"checksum" text,
"storage_key" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "email_sync_state" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"account_id" uuid NOT NULL,
"mailbox_id" uuid NOT NULL,
"mailbox_path" text NOT NULL,
"uid_validity" bigint,
"highest_uid" bigint DEFAULT 0 NOT NULL,
"mod_seq" text,
"last_synced_at" timestamp with time zone,
"sync_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "email_mailboxes" ADD CONSTRAINT "email_mailboxes_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_mailboxes" ADD CONSTRAINT "email_mailboxes_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 "email_mailboxes" ADD CONSTRAINT "email_mailboxes_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_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 "email_messages" ADD CONSTRAINT "email_messages_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_mailbox_id_email_mailboxes_id_fk" FOREIGN KEY ("mailbox_id") REFERENCES "public"."email_mailboxes"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_message_bodies" ADD CONSTRAINT "email_message_bodies_message_id_email_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_attachments" ADD CONSTRAINT "email_attachments_message_id_email_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_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 "email_sync_state" ADD CONSTRAINT "email_sync_state_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_mailbox_id_email_mailboxes_id_fk" FOREIGN KEY ("mailbox_id") REFERENCES "public"."email_mailboxes"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "email_mailboxes_account_path_key" ON "email_mailboxes" USING btree ("account_id","path");--> statement-breakpoint
CREATE INDEX "email_mailboxes_tenant_account_idx" ON "email_mailboxes" USING btree ("tenant_id","account_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_messages_mailbox_uid_key" ON "email_messages" USING btree ("mailbox_id","uid");--> statement-breakpoint
CREATE INDEX "email_messages_account_mailbox_idx" ON "email_messages" USING btree ("account_id","mailbox_path");--> statement-breakpoint
CREATE INDEX "email_messages_received_idx" ON "email_messages" USING btree ("received_at");--> statement-breakpoint
CREATE INDEX "email_messages_message_id_idx" ON "email_messages" USING btree ("message_id");--> statement-breakpoint
CREATE INDEX "email_messages_thread_idx" ON "email_messages" USING btree ("thread_id");--> statement-breakpoint
CREATE INDEX "email_attachments_message_idx" ON "email_attachments" USING btree ("message_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_sync_state_mailbox_key" ON "email_sync_state" USING btree ("account_id","mailbox_path");--> statement-breakpoint
CREATE INDEX "email_sync_state_tenant_account_idx" ON "email_sync_state" USING btree ("tenant_id","account_id");

View File

@@ -0,0 +1,7 @@
ALTER TABLE "createddocuments" ADD COLUMN "costcentre" uuid;
--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "costcentre" uuid;
--> statement-breakpoint
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_costcentre_costcentres_id_fk" FOREIGN KEY ("costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "services" ADD CONSTRAINT "services_costcentre_costcentres_id_fk" FOREIGN KEY ("costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,43 @@
CREATE TABLE "instance_agents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"token_prefix" text NOT NULL,
"token_hash" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL,
"scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
"printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
"last_seen_at" timestamp with time zone,
"last_debug_info" jsonb,
CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "instance_agent_scan_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant_id" bigint NOT NULL,
"agent_id" uuid NOT NULL,
"requested_by" uuid,
"status" text DEFAULT 'pending' NOT NULL,
"scanner_name" text,
"requested_filename" text,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"target" jsonb DEFAULT '{}'::jsonb NOT NULL,
"agent_message" text,
"attempts" integer DEFAULT 0 NOT NULL,
"claimed_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"file_id" uuid,
CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade
);
--> statement-breakpoint
CREATE INDEX "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at");
--> statement-breakpoint
CREATE INDEX "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "instance_agents" ADD COLUMN "preferred_scanner_name" text;
--> statement-breakpoint
ALTER TABLE "instance_agents" ADD COLUMN "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb;

View File

@@ -243,29 +243,120 @@
{
"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": 35,
"idx": 36,
"version": "7",
"when": 1778194800000,
"tag": "0036_allowed_contracttypes",
"breakpoints": true
},
{
"idx": 36,
"idx": 37,
"version": "7",
"when": 1778840100000,
"tag": "0037_outgoing_sepa_mandates",
"breakpoints": true
},
{
"idx": 37,
"idx": 38,
"version": "7",
"when": 1778840200000,
"tag": "0034_profile_availability_note",
"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
},
{
"idx": 42,
"version": "7",
"when": 1780153200000,
"tag": "0042_profile_availability_note",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1780156800000,
"tag": "0043_communication_rooms",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1780160400000,
"tag": "0044_telephony_calls",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1780164000000,
"tag": "0045_telephony_trunks",
"breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1780167600000,
"tag": "0046_telephony_trunk_nat",
"breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1780171200000,
"tag": "0047_telephony_extensions",
"breakpoints": true
},
{
"idx": 48,
"version": "7",
"when": 1780174800000,
"tag": "0048_mobile_push_devices",
"breakpoints": true
},
{
"idx": 49,
"version": "7",
"when": 1780178400000,
"tag": "0049_email_cache",
"breakpoints": true
},
{
"idx": 50,
"version": "7",
"when": 1780261200000,
"tag": "0050_outgoing_document_costcentres",
"breakpoints": true
}
]

View File

@@ -63,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"),

View File

@@ -20,6 +20,7 @@ import { plants } from "./plants"
import { authUsers } from "./auth_users"
import {serialExecutions} from "./serialexecutions";
import { outgoingsepamandates } from "./outgoingsepamandates"
import { costcentres } from "./costcentres"
export const createddocuments = pgTable("createddocuments", {
id: bigint("id", { mode: "number" })
@@ -49,6 +50,8 @@ export const createddocuments = pgTable("createddocuments", {
() => projects.id
),
costcentre: uuid("costcentre").references(() => costcentres.id),
documentNumber: text("documentNumber"),
documentDate: text("documentDate"),

208
backend/db/schema/emails.ts Normal file
View File

@@ -0,0 +1,208 @@
import {
bigint,
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { userCredentials } from "./user_credentials"
export const emailMailboxes = pgTable(
"email_mailboxes",
{
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" }),
accountId: uuid("account_id")
.notNull()
.references(() => userCredentials.id, { onDelete: "cascade", onUpdate: "cascade" }),
path: text("path").notNull(),
delimiter: text("delimiter"),
name: text("name").notNull(),
specialUse: text("special_use"),
flags: jsonb("flags").$type<string[]>(),
exists: integer("exists").notNull().default(0),
unseen: integer("unseen").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
},
(table) => ({
accountPathKey: uniqueIndex("email_mailboxes_account_path_key")
.on(table.accountId, table.path),
tenantAccountIdx: index("email_mailboxes_tenant_account_idx")
.on(table.tenantId, table.accountId),
}),
)
export const emailMessages = pgTable(
"email_messages",
{
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" }),
accountId: uuid("account_id")
.notNull()
.references(() => userCredentials.id, { onDelete: "cascade", onUpdate: "cascade" }),
mailboxId: uuid("mailbox_id")
.notNull()
.references(() => emailMailboxes.id, { onDelete: "cascade", onUpdate: "cascade" }),
mailboxPath: text("mailbox_path").notNull(),
uid: bigint("uid", { mode: "number" }).notNull(),
emailId: text("email_id"),
messageId: text("message_id"),
inReplyTo: text("in_reply_to"),
threadId: text("thread_id"),
subject: text("subject"),
from: jsonb("from").$type<Array<{ name?: string | null; address?: string | null }>>(),
to: jsonb("to").$type<Array<{ name?: string | null; address?: string | null }>>(),
cc: jsonb("cc").$type<Array<{ name?: string | null; address?: string | null }>>(),
bcc: jsonb("bcc").$type<Array<{ name?: string | null; address?: string | null }>>(),
replyTo: jsonb("reply_to").$type<Array<{ name?: string | null; address?: string | null }>>(),
preview: text("preview"),
flags: jsonb("flags").$type<string[]>(),
seen: boolean("seen").notNull().default(false),
flagged: boolean("flagged").notNull().default(false),
hasAttachments: boolean("has_attachments").notNull().default(false),
size: bigint("size", { mode: "number" }),
sentAt: timestamp("sent_at", { withTimezone: true }),
receivedAt: timestamp("received_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
},
(table) => ({
mailboxUidKey: uniqueIndex("email_messages_mailbox_uid_key")
.on(table.mailboxId, table.uid),
accountMailboxIdx: index("email_messages_account_mailbox_idx")
.on(table.accountId, table.mailboxPath),
receivedIdx: index("email_messages_received_idx")
.on(table.receivedAt),
messageIdIdx: index("email_messages_message_id_idx")
.on(table.messageId),
threadIdx: index("email_messages_thread_idx")
.on(table.threadId),
}),
)
export const emailMessageBodies = pgTable("email_message_bodies", {
messageId: uuid("message_id")
.primaryKey()
.references(() => emailMessages.id, { onDelete: "cascade", onUpdate: "cascade" }),
text: text("text"),
html: text("html"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
})
export const emailAttachments = pgTable(
"email_attachments",
{
id: uuid("id").primaryKey().defaultRandom(),
messageId: uuid("message_id")
.notNull()
.references(() => emailMessages.id, { onDelete: "cascade", onUpdate: "cascade" }),
filename: text("filename"),
contentType: text("content_type"),
contentId: text("content_id"),
disposition: text("disposition"),
size: bigint("size", { mode: "number" }),
checksum: text("checksum"),
storageKey: text("storage_key"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
messageIdx: index("email_attachments_message_idx")
.on(table.messageId),
}),
)
export const emailSyncState = pgTable(
"email_sync_state",
{
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" }),
accountId: uuid("account_id")
.notNull()
.references(() => userCredentials.id, { onDelete: "cascade", onUpdate: "cascade" }),
mailboxId: uuid("mailbox_id")
.notNull()
.references(() => emailMailboxes.id, { onDelete: "cascade", onUpdate: "cascade" }),
mailboxPath: text("mailbox_path").notNull(),
uidValidity: bigint("uid_validity", { mode: "number" }),
highestUid: bigint("highest_uid", { mode: "number" }).notNull().default(0),
modSeq: text("mod_seq"),
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }),
syncError: text("sync_error"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
},
(table) => ({
mailboxKey: uniqueIndex("email_sync_state_mailbox_key")
.on(table.accountId, table.mailboxPath),
tenantAccountIdx: index("email_sync_state_tenant_account_idx")
.on(table.tenantId, table.accountId),
}),
)
export type EmailMailbox = typeof emailMailboxes.$inferSelect
export type NewEmailMailbox = typeof emailMailboxes.$inferInsert
export type EmailMessage = typeof emailMessages.$inferSelect
export type NewEmailMessage = typeof emailMessages.$inferInsert
export type EmailMessageBody = typeof emailMessageBodies.$inferSelect
export type NewEmailMessageBody = typeof emailMessageBodies.$inferInsert
export type EmailAttachment = typeof emailAttachments.$inferSelect
export type NewEmailAttachment = typeof emailAttachments.$inferInsert
export type EmailSyncState = typeof emailSyncState.$inferSelect
export type NewEmailSyncState = typeof emailSyncState.$inferInsert

View File

@@ -32,6 +32,9 @@ export const events = pgTable(
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

@@ -27,6 +27,7 @@ export * from "./customerspaces"
export * from "./customerinventoryitems"
export * from "./devices"
export * from "./documentboxes"
export * from "./emails"
export * from "./enums"
export * from "./events"
export * from "./entitybankaccounts"
@@ -47,6 +48,8 @@ export * from "./historyitems"
export * from "./holidays"
export * from "./hourrates"
export * from "./incominginvoices"
export * from "./instance_agents"
export * from "./instance_agent_scan_jobs"
export * from "./inventoryitemgroups"
export * from "./inventoryitems"
export * from "./letterheads"
@@ -55,6 +58,7 @@ export * from "./movements"
export * from "./m2m_api_keys"
export * from "./notifications_event_types"
export * from "./notifications_items"
export * from "./notification_mobile_push_devices"
export * from "./notifications_preferences"
export * from "./notifications_preferences_defaults"
export * from "./notification_push_subscriptions"
@@ -75,6 +79,9 @@ export * from "./statementallocations"
export * from "./tasks"
export * from "./teams"
export * from "./taxtypes"
export * from "./telephony_calls"
export * from "./telephony_extensions"
export * from "./telephony_trunks"
export * from "./tenants"
export * from "./texttemplates"
export * from "./units"

View File

@@ -0,0 +1,59 @@
import {
pgTable,
uuid,
timestamp,
text,
bigint,
jsonb,
integer,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { files } from "./files"
import { instanceAgents } from "./instance_agents"
export const instanceAgentScanJobs = pgTable("instance_agent_scan_jobs", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
agentId: uuid("agent_id")
.notNull()
.references(() => instanceAgents.id, { onDelete: "cascade", onUpdate: "cascade" }),
requestedBy: uuid("requested_by").references(() => authUsers.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
status: text("status").notNull().default("pending"),
scannerName: text("scanner_name"),
requestedFilename: text("requested_filename"),
settings: jsonb("settings").notNull().default({}),
target: jsonb("target").notNull().default({}),
agentMessage: text("agent_message"),
attempts: integer("attempts").notNull().default(0),
claimedAt: timestamp("claimed_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
fileId: uuid("file_id").references(() => files.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
})
export type InstanceAgentScanJob = typeof instanceAgentScanJobs.$inferSelect
export type NewInstanceAgentScanJob = typeof instanceAgentScanJobs.$inferInsert

View File

@@ -0,0 +1,47 @@
import {
pgTable,
uuid,
timestamp,
text,
boolean,
jsonb,
} from "drizzle-orm/pg-core"
export const instanceAgents = pgTable("instance_agents", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
description: text("description"),
tokenPrefix: text("token_prefix").notNull(),
tokenHash: text("token_hash").notNull().unique(),
active: boolean("active").notNull().default(true),
capabilities: jsonb("capabilities").notNull().default({ scan: true, print: false }),
scannerNames: jsonb("scanner_names").notNull().default([]),
printerNames: jsonb("printer_names").notNull().default([]),
preferredScannerName: text("preferred_scanner_name"),
scanDefaults: jsonb("scan_defaults").notNull().default({
format: "pdf",
resolution: 300,
mode: "Color",
source: null,
postprocess: false,
postprocessProfile: "document",
}),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
lastDebugInfo: jsonb("last_debug_info"),
})
export type InstanceAgent = typeof instanceAgents.$inferSelect
export type NewInstanceAgent = typeof instanceAgents.$inferInsert

View File

@@ -0,0 +1,53 @@
import {
pgTable,
uuid,
bigint,
text,
jsonb,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const notificationMobilePushDevices = pgTable(
"notification_mobile_push_devices",
{
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" }),
localDeviceId: text("local_device_id").notNull(),
centralDeviceId: text("central_device_id").notNull(),
platform: text("platform").notNull(),
providerTokenPreview: text("provider_token_preview"),
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) => ({
uniqueUserDevice: uniqueIndex("notification_mobile_push_devices_user_device_key")
.on(table.tenantId, table.userId, table.localDeviceId),
uniqueCentralDevice: uniqueIndex("notification_mobile_push_devices_central_device_key")
.on(table.centralDeviceId),
}),
)
export type NotificationMobilePushDevice =
typeof notificationMobilePushDevices.$inferSelect
export type NewNotificationMobilePushDevice =
typeof notificationMobilePushDevices.$inferInsert

View File

@@ -13,6 +13,7 @@ import {
import { tenants } from "./tenants"
import { units } from "./units"
import { authUsers } from "./auth_users"
import { costcentres } from "./costcentres"
export const services = pgTable("services", {
id: bigint("id", { mode: "number" })
@@ -35,6 +36,8 @@ export const services = pgTable("services", {
unit: bigint("unit", { mode: "number" }).references(() => units.id),
costcentre: uuid("costcentre").references(() => costcentres.id),
serviceNumber: bigint("serviceNumber", { mode: "number" }),
tags: jsonb("tags").default([]),

View File

@@ -0,0 +1,56 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
integer,
index,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const telephonyCalls = pgTable(
"telephony_calls",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
direction: text("direction").notNull(),
status: text("status").notNull().default("ringing"),
localExtension: text("local_extension"),
remoteNumber: text("remote_number"),
remoteDisplayName: text("remote_display_name"),
sipCallId: text("sip_call_id"),
startedAt: timestamp("started_at", { withTimezone: true })
.notNull()
.defaultNow(),
answeredAt: timestamp("answered_at", { withTimezone: true }),
endedAt: timestamp("ended_at", { withTimezone: true }),
durationSeconds: integer("duration_seconds"),
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) => ({
tenantStartedIdx: index("telephony_calls_tenant_started_idx")
.on(table.tenantId, table.startedAt),
createdByIdx: index("telephony_calls_created_by_idx")
.on(table.tenantId, table.createdBy),
sipCallIdx: index("telephony_calls_sip_call_idx")
.on(table.tenantId, table.sipCallId),
})
)
export type TelephonyCall = typeof telephonyCalls.$inferSelect
export type NewTelephonyCall = typeof telephonyCalls.$inferInsert

View File

@@ -0,0 +1,53 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
boolean,
index,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { teams } from "./teams"
import { branches } from "./branches"
export const telephonyExtensions = pgTable(
"telephony_extensions",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
targetType: text("target_type").notNull(),
targetUserId: uuid("target_user_id").references(() => authUsers.id, { onDelete: "cascade" }),
targetTeamId: bigint("target_team_id", { mode: "number" }).references(() => teams.id, { onDelete: "cascade" }),
targetBranchId: bigint("target_branch_id", { mode: "number" }).references(() => branches.id, { onDelete: "cascade" }),
extension: text("extension").notNull(),
displayName: text("display_name"),
sipUsername: text("sip_username"),
sipPassword: text("sip_password"),
enabled: boolean("enabled").notNull().default(true),
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) => ({
tenantExtensionIdx: uniqueIndex("telephony_extensions_tenant_extension_idx")
.on(table.tenantId, table.extension),
tenantTargetIdx: index("telephony_extensions_tenant_target_idx")
.on(table.tenantId, table.targetType),
})
)
export type TelephonyExtension = typeof telephonyExtensions.$inferSelect
export type NewTelephonyExtension = typeof telephonyExtensions.$inferInsert

View File

@@ -0,0 +1,53 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
boolean,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { telephonyExtensions } from "./telephony_extensions"
export const telephonyTrunks = pgTable(
"telephony_trunks",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
provider: text("provider").notNull().default("telekom"),
enabled: boolean("enabled").notNull().default(false),
registrar: text("registrar").notNull().default("tel.t-online.de"),
sipUser: text("sip_user"),
authUser: text("auth_user"),
password: text("password"),
callerId: text("caller_id"),
inboundExtension: text("inbound_extension").notNull().default("1001"),
defaultRouteExtensionId: uuid("default_route_extension_id").references(() => telephonyExtensions.id, { onDelete: "set null" }),
outboundPrefix: text("outbound_prefix").notNull().default("0"),
externalSignalingAddress: text("external_signaling_address"),
externalMediaAddress: text("external_media_address"),
localNetworks: text("local_networks"),
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) => ({
tenantProviderIdx: uniqueIndex("telephony_trunks_tenant_provider_idx")
.on(table.tenantId, table.provider),
})
)
export type TelephonyTrunk = typeof telephonyTrunks.$inferSelect
export type NewTelephonyTrunk = typeof telephonyTrunks.$inferInsert

View File

@@ -32,6 +32,9 @@ import wikiRoutes from "./routes/wiki";
import portalContractRoutes from "./routes/portal/contracts";
import mcpRoutes from "./routes/mcp";
import communicationRoutes from "./routes/communication";
import telephonyRoutes from "./routes/telephony";
import instanceAgentRoutes from "./routes/instanceAgents";
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -57,6 +60,7 @@ import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3";
import { runBootstrap } from "./modules/bootstrap.service";
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
//Services
@@ -82,6 +86,7 @@ async function main() {
await app.register(dbPlugin);
await app.register(servicesPlugin);
await runBootstrap(app);
startMatrixPushWorker(app);
app.addHook('preHandler', (req, reply, done) => {
console.log(req.method)
@@ -125,6 +130,10 @@ async function main() {
await devicesApp.register(devicesManagementRoutes)
},{prefix: "/devices"})
await app.register(async (agentApp) => {
await agentApp.register(instanceAgentGatewayRoutes)
},{prefix: "/instance-agent"})
await app.register(corsPlugin);
//Geschützte Routes
@@ -154,6 +163,8 @@ async function main() {
await subApp.register(portalContractRoutes);
await subApp.register(mcpRoutes);
await subApp.register(communicationRoutes);
await subApp.register(telephonyRoutes);
await subApp.register(instanceAgentRoutes);
},{prefix: "/api"})

View File

@@ -205,6 +205,8 @@ const buildOutgoingDocumentPayload = (
if (args[field] !== undefined) payload[field] = numberArg(args, field)
}
if (args.costcentre !== undefined) payload.costcentre = stringArg(args, "costcentre")
for (const field of ["paymentDays"] as const) {
if (args[field] !== undefined) payload[field] = numberArg(args, field)
}
@@ -458,6 +460,7 @@ export const accountingTools: McpTool[] = [
contact: { type: "number" },
contract: { type: "number" },
project: { type: "number" },
costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." },
plant: { type: "number" },
documentDate: { type: "string" },
deliveryDate: { type: "string" },
@@ -512,6 +515,7 @@ export const accountingTools: McpTool[] = [
contact: { type: "number" },
contract: { type: "number" },
project: { type: "number" },
costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." },
plant: { type: "number" },
documentDate: { type: "string" },
deliveryDate: { type: "string" },

View File

@@ -21,6 +21,7 @@ import {
texttemplates,
units,
} from "../../db/schema"
import { matrixService } from "./matrix.service"
const adminPermissions = [
"mcp.tokens.write",
@@ -116,12 +117,12 @@ async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: numbe
const timestamp = new Date()
const tagDefaults = [
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices" },
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes" },
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders" },
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes" },
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices" },
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders" },
{ 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) {
@@ -487,4 +488,19 @@ export async function runBootstrap(server: FastifyInstance) {
await ensureTenantBaseData(server, tenant.id, adminUser.id)
console.log("✅ Bootstrap-Grunddaten geprüft")
if (process.env.FEDEO_BOOTSTRAP_MATRIX === "true") {
try {
const matrix = matrixService(server)
await matrix.provisionTenantRoom(adminUser.id, tenant.id, {
key: "allgemein",
name: "Allgemeiner Chat",
type: "general",
})
console.log("✅ Bootstrap-Matrix-Kommunikation geprüft")
} catch (err) {
console.error("❌ Bootstrap-Matrix-Kommunikation fehlgeschlagen:", err)
throw err
}
}
}

View File

@@ -0,0 +1,864 @@
import { FastifyInstance } from "fastify"
import { and, asc, desc, eq } from "drizzle-orm"
import { ImapFlow } from "imapflow"
import { simpleParser } from "mailparser"
import {
emailAttachments,
emailMailboxes,
emailMessageBodies,
emailMessages,
emailSyncState,
userCredentials,
} from "../../../db/schema"
import { decrypt } from "../../utils/crypt"
type EmailAddress = {
name?: string | null
address?: string | null
}
type SyncOptions = {
mailbox?: string
limit?: number
}
type MailAccountConnection = {
id: string
tenantId: number
userId: string
email: string
password: string
imapHost: string
imapPort: number
imapSsl: boolean
}
const decryptValue = (value: unknown) => value ? decrypt(value as any) : ""
const normalizeAddressList = (addresses: any): EmailAddress[] => {
const value = Array.isArray(addresses?.value) ? addresses.value : []
return value.map((item: any) => ({
name: item.name || null,
address: item.address || null,
}))
}
const previewText = (text?: string | false | null) => {
if (!text) return null
return text.replace(/\s+/g, " ").trim().slice(0, 240) || null
}
const attachmentFilename = (node: any) =>
node?.dispositionParameters?.filename
|| node?.parameters?.name
|| null
const normalizeContentId = (value?: string | null) =>
value ? value.replace(/^<|>$/g, "") : null
const collectAttachmentParts = (node: any, parts: any[] = []) => {
if (!node) return parts
if (node.part && !node.childNodes?.length) {
const filename = attachmentFilename(node)
const disposition = String(node.disposition || "").toLowerCase()
if (filename || node.id || ["attachment", "inline"].includes(disposition)) {
parts.push(node)
}
}
for (const child of node.childNodes || []) {
collectAttachmentParts(child, parts)
}
return parts
}
const findAttachmentPart = (bodyStructure: any, attachment: any) => {
const parts = collectAttachmentParts(bodyStructure)
if (!parts.length) return null
const scored = parts
.map((part) => {
let score = 0
if (attachmentFilename(part) && attachmentFilename(part) === attachment.filename) score += 4
if (part.type && part.type === attachment.contentType) score += 3
if (Number(part.size || 0) === Number(attachment.size || 0)) score += 2
if (
normalizeContentId(part.id)
&& normalizeContentId(part.id) === normalizeContentId(attachment.contentId)
) {
score += 3
}
return { part, score }
})
.sort((a, b) => b.score - a.score)
if (scored[0]?.score > 0) return scored[0].part
return parts.length === 1 ? parts[0] : null
}
const streamToBuffer = async (stream: any, timeoutMs = 45_000) => {
const chunks: Buffer[] = []
let timeout: NodeJS.Timeout | null = null
try {
await Promise.race([
(async () => {
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
})(),
new Promise((_, reject) => {
timeout = setTimeout(() => {
if (typeof stream.destroy === "function") {
stream.destroy()
}
reject(new Error("Anhang-Download hat zu lange gedauert"))
}, timeoutMs)
}),
])
} finally {
if (timeout) clearTimeout(timeout)
}
return Buffer.concat(chunks)
}
const flagsFromMessage = (flags: Set<string> | string[] | undefined) => {
if (!flags) return []
return Array.isArray(flags) ? flags : Array.from(flags)
}
const mailboxDisplayName = (path: string) => {
const parts = path.split(/[/.]/).filter(Boolean)
return parts[parts.length - 1] || path
}
export function emailSyncService(server: FastifyInstance) {
const getAccount = async (
tenantId: number,
userId: string,
accountId: string,
): Promise<MailAccountConnection | null> => {
const rows = await server.db
.select()
.from(userCredentials)
.where(and(
eq(userCredentials.id, accountId),
eq(userCredentials.tenantId, tenantId),
eq(userCredentials.userId, userId),
eq(userCredentials.type, "mail"),
))
.limit(1)
const row = rows[0]
if (!row) return null
return {
id: row.id,
tenantId: row.tenantId,
userId: row.userId,
email: decryptValue(row.emailEncrypted),
password: decryptValue(row.passwordEncrypted),
imapHost: decryptValue(row.imapHostEncrypted),
imapPort: Number(row.imapPort || 993),
imapSsl: row.imapSsl !== false,
}
}
const createClient = (account: MailAccountConnection) => new ImapFlow({
host: account.imapHost,
port: account.imapPort,
secure: account.imapSsl,
auth: {
user: account.email,
pass: account.password,
},
logger: false,
})
const upsertMailbox = async (
account: MailAccountConnection,
mailbox: any,
status?: { exists?: number; unseen?: number },
) => {
const path = mailbox.path || mailbox.name
const [saved] = await server.db
.insert(emailMailboxes)
.values({
tenantId: account.tenantId,
userId: account.userId,
accountId: account.id,
path,
delimiter: mailbox.delimiter || null,
name: mailbox.name || mailboxDisplayName(path),
specialUse: mailbox.specialUse || null,
flags: flagsFromMessage(mailbox.flags),
exists: status?.exists || 0,
unseen: status?.unseen || 0,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [emailMailboxes.accountId, emailMailboxes.path],
set: {
delimiter: mailbox.delimiter || null,
name: mailbox.name || mailboxDisplayName(path),
specialUse: mailbox.specialUse || null,
flags: flagsFromMessage(mailbox.flags),
exists: status?.exists || 0,
unseen: status?.unseen || 0,
updatedAt: new Date(),
},
})
.returning()
return saved
}
const syncMailboxes = async (account: MailAccountConnection, client: ImapFlow) => {
const savedMailboxes = []
for await (const mailbox of await client.list()) {
savedMailboxes.push(await upsertMailbox(account, mailbox))
}
return savedMailboxes
}
const loadSyncState = async (account: MailAccountConnection, mailbox: any) => {
const rows = await server.db
.select()
.from(emailSyncState)
.where(and(
eq(emailSyncState.accountId, account.id),
eq(emailSyncState.mailboxPath, mailbox.path),
))
.limit(1)
return rows[0] || null
}
const saveSyncState = async (
account: MailAccountConnection,
mailbox: any,
highestUid: number,
uidValidity?: number | null,
syncError?: string | null,
) => {
await server.db
.insert(emailSyncState)
.values({
tenantId: account.tenantId,
userId: account.userId,
accountId: account.id,
mailboxId: mailbox.id,
mailboxPath: mailbox.path,
uidValidity: uidValidity || null,
highestUid,
lastSyncedAt: new Date(),
syncError: syncError || null,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [emailSyncState.accountId, emailSyncState.mailboxPath],
set: {
mailboxId: mailbox.id,
uidValidity: uidValidity || null,
highestUid,
lastSyncedAt: new Date(),
syncError: syncError || null,
updatedAt: new Date(),
},
})
}
const storeMessage = async (
account: MailAccountConnection,
mailbox: any,
message: any,
) => {
if (!message.source) return null
const parsed = await simpleParser(message.source)
const flags = flagsFromMessage(message.flags)
const receivedAt = parsed.date || message.envelope?.date || new Date()
const threadId = parsed.inReplyTo || parsed.references?.[0] || parsed.messageId || message.emailId || null
const [saved] = await server.db
.insert(emailMessages)
.values({
tenantId: account.tenantId,
userId: account.userId,
accountId: account.id,
mailboxId: mailbox.id,
mailboxPath: mailbox.path,
uid: Number(message.uid),
emailId: message.emailId || null,
messageId: parsed.messageId || null,
inReplyTo: parsed.inReplyTo || null,
threadId,
subject: parsed.subject || "(kein Betreff)",
from: normalizeAddressList(parsed.from),
to: normalizeAddressList(parsed.to),
cc: normalizeAddressList(parsed.cc),
bcc: normalizeAddressList(parsed.bcc),
replyTo: normalizeAddressList(parsed.replyTo),
preview: previewText(parsed.text),
flags,
seen: flags.includes("\\Seen"),
flagged: flags.includes("\\Flagged"),
hasAttachments: Boolean(parsed.attachments?.length),
size: message.size || null,
sentAt: parsed.date || null,
receivedAt,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [emailMessages.mailboxId, emailMessages.uid],
set: {
emailId: message.emailId || null,
messageId: parsed.messageId || null,
inReplyTo: parsed.inReplyTo || null,
threadId,
subject: parsed.subject || "(kein Betreff)",
from: normalizeAddressList(parsed.from),
to: normalizeAddressList(parsed.to),
cc: normalizeAddressList(parsed.cc),
bcc: normalizeAddressList(parsed.bcc),
replyTo: normalizeAddressList(parsed.replyTo),
preview: previewText(parsed.text),
flags,
seen: flags.includes("\\Seen"),
flagged: flags.includes("\\Flagged"),
hasAttachments: Boolean(parsed.attachments?.length),
size: message.size || null,
sentAt: parsed.date || null,
receivedAt,
updatedAt: new Date(),
},
})
.returning()
await server.db
.insert(emailMessageBodies)
.values({
messageId: saved.id,
text: parsed.text || null,
html: parsed.html || null,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: emailMessageBodies.messageId,
set: {
text: parsed.text || null,
html: parsed.html || null,
updatedAt: new Date(),
},
})
if (parsed.attachments?.length) {
for (const attachment of parsed.attachments) {
await server.db
.insert(emailAttachments)
.values({
messageId: saved.id,
filename: attachment.filename || null,
contentType: attachment.contentType || null,
contentId: attachment.contentId || null,
disposition: attachment.contentDisposition || null,
size: attachment.size || null,
checksum: attachment.checksum || null,
})
.onConflictDoNothing()
}
}
return saved
}
const updateCachedMessageFlags = async (
mailboxId: string,
uid: number,
flags: string[],
) => {
await server.db
.update(emailMessages)
.set({
flags,
seen: flags.includes("\\Seen"),
flagged: flags.includes("\\Flagged"),
updatedAt: new Date(),
})
.where(and(
eq(emailMessages.mailboxId, mailboxId),
eq(emailMessages.uid, uid),
))
}
const syncMailboxMessages = async (
account: MailAccountConnection,
client: ImapFlow,
mailbox: any,
limit: number,
) => {
const lock = await client.getMailboxLock(mailbox.path)
let highestUid = 0
try {
const opened: any = await client.mailboxOpen(mailbox.path)
await upsertMailbox(account, mailbox, {
exists: opened.exists || 0,
unseen: opened.unseen || 0,
})
const state = await loadSyncState(account, mailbox)
const searchResult = await client.search({ all: true }, { uid: true })
const allUids = Array.isArray(searchResult) ? searchResult : []
const newUids = allUids
.filter((uid: number) => !state?.highestUid || uid > state.highestUid)
.slice(-limit)
const flagSyncUids = allUids.slice(-limit)
highestUid = Math.max(state?.highestUid || 0, ...newUids, 0)
if (flagSyncUids.length) {
for await (const message of client.fetch(flagSyncUids, {
uid: true,
flags: true,
}, { uid: true })) {
await updateCachedMessageFlags(
mailbox.id,
Number(message.uid),
flagsFromMessage(message.flags),
)
}
}
if (newUids.length) {
for await (const message of client.fetch(newUids, {
uid: true,
envelope: true,
flags: true,
source: true,
size: true,
}, { uid: true })) {
await storeMessage(account, mailbox, message)
}
}
await saveSyncState(account, mailbox, highestUid, Number(opened.uidValidity || 0))
return { path: mailbox.path, fetched: newUids.length, highestUid }
} catch (err: any) {
await saveSyncState(account, mailbox, highestUid, null, err.message || "Sync fehlgeschlagen")
throw err
} finally {
lock.release()
}
}
const syncAccount = async (
tenantId: number,
userId: string,
accountId: string,
options: SyncOptions = {},
) => {
const account = await getAccount(tenantId, userId, accountId)
if (!account) {
throw new Error("E-Mail Konto nicht gefunden")
}
const client = createClient(account)
const limit = Math.min(Math.max(Number(options.limit || 50), 1), 200)
await client.connect()
try {
const mailboxes = await syncMailboxes(account, client)
const syncTargets = options.mailbox
? mailboxes.filter((mailbox) => mailbox.path === options.mailbox)
: mailboxes.filter((mailbox) => mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX")
const synced = []
for (const mailbox of syncTargets.length ? syncTargets : mailboxes.slice(0, 1)) {
synced.push(await syncMailboxMessages(account, client, mailbox, limit))
}
return {
accountId,
mailboxes: mailboxes.length,
synced,
}
} finally {
await client.logout().catch(() => client.close())
}
}
const setMessageSeen = async (
tenantId: number,
userId: string,
messageId: string,
seen: boolean,
) => {
const rows = await server.db
.select()
.from(emailMessages)
.where(and(
eq(emailMessages.tenantId, tenantId),
eq(emailMessages.userId, userId),
eq(emailMessages.id, messageId),
))
.limit(1)
const message = rows[0]
if (!message) return null
const account = await getAccount(tenantId, userId, message.accountId)
if (!account) {
throw new Error("E-Mail Konto nicht gefunden")
}
const client = createClient(account)
await client.connect()
try {
const lock = await client.getMailboxLock(message.mailboxPath)
try {
await client.mailboxOpen(message.mailboxPath)
if (seen) {
await client.messageFlagsAdd(message.uid, ["\\Seen"], { uid: true })
} else {
await client.messageFlagsRemove(message.uid, ["\\Seen"], { uid: true })
}
} finally {
lock.release()
}
} finally {
await client.logout().catch(() => client.close())
}
const currentFlags = Array.isArray(message.flags) ? message.flags : []
const nextFlags = seen
? Array.from(new Set([...currentFlags, "\\Seen"]))
: currentFlags.filter((flag) => flag !== "\\Seen")
const [updated] = await server.db
.update(emailMessages)
.set({
flags: nextFlags,
seen,
updatedAt: new Date(),
})
.where(eq(emailMessages.id, messageId))
.returning()
return updated
}
const loadMessageForAction = async (tenantId: number, userId: string, messageId: string) => {
const rows = await server.db
.select()
.from(emailMessages)
.where(and(
eq(emailMessages.tenantId, tenantId),
eq(emailMessages.userId, userId),
eq(emailMessages.id, messageId),
))
.limit(1)
return rows[0] || null
}
const deleteCachedMessage = async (messageId: string) => {
await server.db
.delete(emailMessages)
.where(eq(emailMessages.id, messageId))
}
const moveMessage = async (
tenantId: number,
userId: string,
messageId: string,
destinationMailboxPath: string,
) => {
const message = await loadMessageForAction(tenantId, userId, messageId)
if (!message) return null
if (message.mailboxPath === destinationMailboxPath) {
return message
}
const account = await getAccount(tenantId, userId, message.accountId)
if (!account) {
throw new Error("E-Mail Konto nicht gefunden")
}
const targetRows = await server.db
.select({ path: emailMailboxes.path })
.from(emailMailboxes)
.where(and(
eq(emailMailboxes.tenantId, tenantId),
eq(emailMailboxes.userId, userId),
eq(emailMailboxes.accountId, message.accountId),
eq(emailMailboxes.path, destinationMailboxPath),
))
.limit(1)
if (!targetRows[0]) {
throw new Error("Zielordner nicht gefunden")
}
const client = createClient(account)
await client.connect()
try {
const lock = await client.getMailboxLock(message.mailboxPath)
try {
await client.mailboxOpen(message.mailboxPath)
await client.messageMove(message.uid, destinationMailboxPath, { uid: true })
} finally {
lock.release()
}
} finally {
await client.logout().catch(() => client.close())
}
await deleteCachedMessage(messageId)
return { success: true, destinationMailboxPath }
}
const archiveMessage = async (tenantId: number, userId: string, messageId: string) => {
const message = await loadMessageForAction(tenantId, userId, messageId)
if (!message) return null
const mailboxes = await server.db
.select()
.from(emailMailboxes)
.where(and(
eq(emailMailboxes.tenantId, tenantId),
eq(emailMailboxes.userId, userId),
eq(emailMailboxes.accountId, message.accountId),
))
const archiveMailbox = mailboxes.find((mailbox) => mailbox.specialUse === "\\Archive")
|| mailboxes.find((mailbox) => ["archive", "archiv"].includes(mailbox.name.toLowerCase()))
|| mailboxes.find((mailbox) => ["archive", "archiv"].includes(mailbox.path.toLowerCase()))
if (!archiveMailbox) {
throw new Error("Kein Archivordner gefunden")
}
return await moveMessage(tenantId, userId, messageId, archiveMailbox.path)
}
const deleteMessage = async (tenantId: number, userId: string, messageId: string) => {
const message = await loadMessageForAction(tenantId, userId, messageId)
if (!message) return null
const account = await getAccount(tenantId, userId, message.accountId)
if (!account) {
throw new Error("E-Mail Konto nicht gefunden")
}
const client = createClient(account)
await client.connect()
try {
const lock = await client.getMailboxLock(message.mailboxPath)
try {
await client.mailboxOpen(message.mailboxPath)
await client.messageDelete(message.uid, { uid: true })
} finally {
lock.release()
}
} finally {
await client.logout().catch(() => client.close())
}
await deleteCachedMessage(messageId)
return { success: true }
}
const getAttachmentContent = async (
tenantId: number,
userId: string,
attachmentId: string,
) => {
const rows = await server.db
.select({
attachment: emailAttachments,
message: emailMessages,
})
.from(emailAttachments)
.innerJoin(emailMessages, eq(emailMessages.id, emailAttachments.messageId))
.where(and(
eq(emailAttachments.id, attachmentId),
eq(emailMessages.tenantId, tenantId),
eq(emailMessages.userId, userId),
))
.limit(1)
const row = rows[0]
if (!row) return null
const messageAttachments = await server.db
.select()
.from(emailAttachments)
.where(eq(emailAttachments.messageId, row.message.id))
.orderBy(asc(emailAttachments.createdAt), asc(emailAttachments.id))
const attachmentIndex = messageAttachments.findIndex((attachment) => attachment.id === attachmentId)
const account = await getAccount(tenantId, userId, row.message.accountId)
if (!account) {
throw new Error("E-Mail Konto nicht gefunden")
}
const client = createClient(account)
await client.connect()
try {
const lock = await client.getMailboxLock(row.message.mailboxPath)
try {
await client.mailboxOpen(row.message.mailboxPath)
const structureMessage = await client.fetchOne(String(row.message.uid), {
uid: true,
bodyStructure: true,
}, { uid: true })
const attachmentPart = structureMessage
? findAttachmentPart(structureMessage.bodyStructure, row.attachment)
: null
if (attachmentPart?.part) {
const downloaded = await client.download(String(row.message.uid), attachmentPart.part, { uid: true })
const content = await streamToBuffer(downloaded.content)
if (content.length) {
return {
filename: downloaded.meta?.filename
|| attachmentFilename(attachmentPart)
|| row.attachment.filename
|| "anhang",
contentType: downloaded.meta?.contentType
|| attachmentPart.type
|| row.attachment.contentType
|| "application/octet-stream",
content,
}
}
}
const fetched = await client.fetchOne(String(row.message.uid), {
source: true,
}, { uid: true })
if (!fetched || !fetched.source) return null
const parsed = await simpleParser(fetched.source)
const matchedAttachment = parsed.attachments.find((item) =>
(row.attachment.checksum && item.checksum === row.attachment.checksum)
|| (
item.filename === row.attachment.filename
&& item.contentType === row.attachment.contentType
&& Number(item.size || 0) === Number(row.attachment.size || 0)
)
)
const attachment = matchedAttachment
|| parsed.attachments[attachmentIndex]
|| (parsed.attachments.length === 1 ? parsed.attachments[0] : null)
if (!attachment) {
return null
}
return {
filename: attachment.filename || row.attachment.filename || "anhang",
contentType: attachment.contentType || row.attachment.contentType || "application/octet-stream",
content: Buffer.isBuffer(attachment.content)
? attachment.content
: Buffer.from(attachment.content),
}
} finally {
lock.release()
}
} finally {
await client.logout().catch(() => client.close())
}
return null
}
const listMailboxes = async (tenantId: number, userId: string, accountId: string) => {
return await server.db
.select()
.from(emailMailboxes)
.where(and(
eq(emailMailboxes.tenantId, tenantId),
eq(emailMailboxes.userId, userId),
eq(emailMailboxes.accountId, accountId),
))
.orderBy(emailMailboxes.specialUse, emailMailboxes.name)
}
const listMessages = async (
tenantId: number,
userId: string,
accountId: string,
mailboxPath = "INBOX",
limit = 50,
) => {
return await server.db
.select()
.from(emailMessages)
.where(and(
eq(emailMessages.tenantId, tenantId),
eq(emailMessages.userId, userId),
eq(emailMessages.accountId, accountId),
eq(emailMessages.mailboxPath, mailboxPath),
))
.orderBy(desc(emailMessages.receivedAt))
.limit(Math.min(Math.max(Number(limit), 1), 200))
}
const getMessage = async (tenantId: number, userId: string, messageId: string) => {
const rows = await server.db
.select({
message: emailMessages,
body: emailMessageBodies,
})
.from(emailMessages)
.leftJoin(emailMessageBodies, eq(emailMessageBodies.messageId, emailMessages.id))
.where(and(
eq(emailMessages.tenantId, tenantId),
eq(emailMessages.userId, userId),
eq(emailMessages.id, messageId),
))
.limit(1)
if (!rows[0]) return null
const attachments = await server.db
.select()
.from(emailAttachments)
.where(eq(emailAttachments.messageId, messageId))
return {
...rows[0].message,
body: rows[0].body,
attachments,
}
}
return {
syncAccount,
listMailboxes,
listMessages,
getMessage,
setMessageSeen,
moveMessage,
archiveMessage,
deleteMessage,
getAttachmentContent,
}
}

View File

@@ -0,0 +1,377 @@
import { createHash } from "node:crypto"
import type { FastifyInstance } from "fastify"
import { and, desc, eq, inArray, isNotNull, ne } from "drizzle-orm"
import { authProfiles, authTenantUsers, authUsers, communicationRooms, notificationsItems } from "../../db/schema"
import { matrixService } from "./matrix.service"
import { NotificationService, UserDirectory } from "./notification.service"
type ChatRecipient = {
userId: string
email?: string | null
firstName?: string | null
lastName?: string | null
fullName?: string | null
matrixUserId?: string
}
type MatrixPushWorkerEvent = {
at: string
type: string
roomKey?: string
roomId?: string | null
messageId?: string
sender?: string
targets?: number
created?: number
delivered?: number
failed?: number
error?: string
}
const matrixPushWorkerState = {
enabled: false,
startedAt: null as string | null,
lastRunAt: null as string | null,
lastJoinAt: null as string | null,
lastJoinTotal: 0,
lastJoinJoined: 0,
lastJoinFailed: 0,
hasSyncToken: false,
lastSyncRooms: 0,
lastSyncMessages: 0,
lastMatchedRooms: 0,
lastNotificationsCreated: 0,
lastNotificationsDelivered: 0,
lastNotificationsFailed: 0,
lastError: null as string | null,
events: [] as MatrixPushWorkerEvent[],
}
const rememberWorkerEvent = (event: MatrixPushWorkerEvent) => {
matrixPushWorkerState.events = [
{
at: new Date().toISOString(),
...event,
},
...matrixPushWorkerState.events,
].slice(0, 25)
}
export const getMatrixPushWorkerState = () => ({
...matrixPushWorkerState,
events: [...matrixPushWorkerState.events],
})
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
}
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 directRoomKey = (firstUserId: string, secondUserId: string) => {
const hash = createHash("sha256")
.update([firstUserId, secondUserId].sort().join(":"))
.digest("hex")
.slice(0, 16)
return `direct_${hash}`
}
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)
}
export function startMatrixPushWorker(server: FastifyInstance) {
if (process.env.MATRIX_PUSH_WORKER_DISABLED === "1") {
server.log.info("Matrix-Push-Worker ist deaktiviert")
return
}
matrixPushWorkerState.enabled = true
matrixPushWorkerState.startedAt = new Date().toISOString()
rememberWorkerEvent({ at: new Date().toISOString(), type: "started" })
const matrix = matrixService(server)
const notifications = new NotificationService(server, getUserDirectory)
const intervalMs = Math.max(Number(process.env.MATRIX_PUSH_WORKER_INTERVAL_MS || 3000), 1000)
let since: string | undefined
let running = false
let stopped = false
let timer: ReturnType<typeof setTimeout> | undefined
let lastServiceJoinSyncAt = 0
let errorBackoffMs = 0
const getTenantRecipients = async (tenantId: number) => {
const rows = 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(eq(authTenantUsers.tenant_id, tenantId))
return await Promise.all(rows.map(async (row) => ({
...row,
matrixUserId: await matrix.matrixUserIdForUser(row.userId, tenantId),
})))
}
const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => {
const rows = await server.db
.select({
payload: notificationsItems.payload,
})
.from(notificationsItems)
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
eq(notificationsItems.eventType, "communication.message.new")
))
.orderBy(desc(notificationsItems.createdAt))
.limit(200)
return rows.some((row) => (row.payload as any)?.messageId === messageId)
}
const recipientsForMessage = (
room: typeof communicationRooms.$inferSelect,
recipients: ChatRecipient[],
senderUserId: string | null,
text: string
) => {
const candidates = senderUserId
? recipients.filter((recipient) => recipient.userId !== senderUserId)
: recipients
const mentioned = new Set(mentionedRecipientIds(text, candidates))
const directRecipients = new Set<string>()
if (room.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
directRecipients.add(room.entityUuid)
} else if (room.type === "direct" && senderUserId) {
candidates
.filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key)
.forEach((recipient) => directRecipients.add(recipient.userId))
}
return candidates
.filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId))
.map((recipient) => ({
...recipient,
mentioned: mentioned.has(recipient.userId),
direct: directRecipients.has(recipient.userId),
}))
}
const deliverMessageNotification = async (
room: typeof communicationRooms.$inferSelect,
message: any,
recipients: ChatRecipient[]
) => {
if (!message.id || message.own) return
const sender = recipients.find((recipient) => recipient.matrixUserId === message.sender) || null
const text = message.body || message.attachment?.fileName || "Neue Nachricht"
const targets = recipientsForMessage(room, recipients, sender?.userId || null, text)
rememberWorkerEvent({
at: new Date().toISOString(),
type: "message_seen",
roomKey: room.key,
roomId: room.matrixRoomId,
messageId: message.id,
sender: message.sender,
targets: targets.length,
})
if (!targets.length) return
const senderName = sender ? displayUserName(sender) : message.senderDisplayName || message.sender || "Matrix"
const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text
for (const target of targets) {
if (await hasChatNotificationForMessage(room.tenantId, target.userId, message.id)) {
rememberWorkerEvent({
at: new Date().toISOString(),
type: "notification_skipped_duplicate",
roomKey: room.key,
roomId: room.matrixRoomId,
messageId: message.id,
sender: message.sender,
targets: 1,
})
continue
}
const result = await notifications.trigger({
tenantId: room.tenantId,
userId: target.userId,
eventType: "communication.message.new",
title: target.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,
matrixSender: message.sender,
mentioned: target.mentioned,
direct: target.direct,
},
channels: ["inapp", "push"],
})
matrixPushWorkerState.lastNotificationsCreated += result.created || 0
matrixPushWorkerState.lastNotificationsDelivered += result.delivered || 0
matrixPushWorkerState.lastNotificationsFailed += result.failed || 0
rememberWorkerEvent({
at: new Date().toISOString(),
type: "notification_triggered",
roomKey: room.key,
roomId: room.matrixRoomId,
messageId: message.id,
sender: message.sender,
targets: 1,
created: result.created || 0,
delivered: result.delivered || 0,
failed: result.failed || 0,
})
}
}
const runOnce = async () => {
if (running || stopped) return
running = true
try {
matrixPushWorkerState.lastRunAt = new Date().toISOString()
matrixPushWorkerState.lastError = null
matrixPushWorkerState.lastSyncRooms = 0
matrixPushWorkerState.lastSyncMessages = 0
matrixPushWorkerState.lastMatchedRooms = 0
matrixPushWorkerState.lastNotificationsCreated = 0
matrixPushWorkerState.lastNotificationsDelivered = 0
matrixPushWorkerState.lastNotificationsFailed = 0
if (!lastServiceJoinSyncAt || Date.now() - lastServiceJoinSyncAt > 60_000) {
const joinResult = await matrix.syncServiceJoinedTenantRooms()
lastServiceJoinSyncAt = Date.now()
matrixPushWorkerState.lastJoinAt = new Date().toISOString()
matrixPushWorkerState.lastJoinTotal = joinResult.total
matrixPushWorkerState.lastJoinJoined = joinResult.joined
matrixPushWorkerState.lastJoinFailed = joinResult.failed
rememberWorkerEvent({
at: new Date().toISOString(),
type: "service_join_sync",
targets: joinResult.total,
delivered: joinResult.joined,
failed: joinResult.failed,
})
if (joinResult.failed) {
console.warn("Matrix-Push-Worker: Service-User konnte nicht alle Räume joinen", {
total: joinResult.total,
joined: joinResult.joined,
failed: joinResult.failed,
})
}
}
const initial = !since
const sync = await matrix.syncServiceRoomEvents(since, initial)
since = sync.nextBatch || since
matrixPushWorkerState.hasSyncToken = Boolean(since)
matrixPushWorkerState.lastSyncRooms = sync.rooms?.length || 0
matrixPushWorkerState.lastSyncMessages = (sync.rooms || [])
.reduce((sum: number, room: any) => sum + (room.messages?.length || 0), 0)
if (!initial && sync.rooms?.length) {
const roomIds = sync.rooms.map((room: any) => room.roomId).filter(Boolean)
const rooms = roomIds.length
? await server.db
.select()
.from(communicationRooms)
.where(and(
inArray(communicationRooms.matrixRoomId, roomIds),
ne(communicationRooms.archived, true),
isNotNull(communicationRooms.matrixRoomId)
))
: []
const roomsByMatrixId = new Map(rooms.map((room) => [room.matrixRoomId, room]))
matrixPushWorkerState.lastMatchedRooms = rooms.length
const recipientsByTenant = new Map<number, ChatRecipient[]>()
for (const syncedRoom of sync.rooms) {
const room = roomsByMatrixId.get(syncedRoom.roomId)
if (!room || !syncedRoom.messages?.length) continue
if (!recipientsByTenant.has(room.tenantId)) {
recipientsByTenant.set(room.tenantId, await getTenantRecipients(room.tenantId))
}
const recipients = recipientsByTenant.get(room.tenantId) || []
for (const message of syncedRoom.messages) {
await deliverMessageNotification(room, message, recipients)
}
}
}
errorBackoffMs = 0
} catch (err) {
matrixPushWorkerState.lastError = err instanceof Error ? err.message : String(err)
const retryAfterMs = Number((err as any)?.retryAfterMs || (err as any)?.body?.retry_after_ms || 0)
errorBackoffMs = Math.min(
Math.max(retryAfterMs || (errorBackoffMs ? errorBackoffMs * 2 : 30_000), 30_000),
5 * 60_000
)
rememberWorkerEvent({
at: new Date().toISOString(),
type: "error",
error: matrixPushWorkerState.lastError,
})
console.error("Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten", err)
server.log.error({ err }, "Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten")
} finally {
running = false
if (!stopped) {
const nextDelay = errorBackoffMs || (since ? 0 : intervalMs)
timer = setTimeout(() => void runOnce(), nextDelay)
}
}
}
timer = setTimeout(() => void runOnce(), intervalMs)
server.addHook("onClose", async () => {
stopped = true
if (timer) clearTimeout(timer)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import webPush from "web-push"
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
import {
authUsers,
notificationMobilePushDevices,
notificationPushSubscriptions,
notificationsEventTypes,
notificationsItems,
@@ -10,6 +11,7 @@ import {
notificationsPreferencesDefaults,
} from "../../db/schema"
import { secrets } from "../utils/secrets"
import { pushServerClient } from "./push-server.client"
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
@@ -280,18 +282,8 @@ export class NotificationService {
}
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
const subscriptions = secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY
? await this.server.db
.select()
.from(notificationPushSubscriptions)
.where(and(
@@ -299,8 +291,18 @@ export class NotificationService {
eq(notificationPushSubscriptions.userId, item.userId),
isNull(notificationPushSubscriptions.disabledAt)
))
: []
if (!subscriptions.length) {
const mobileDevices = await this.server.db
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
.from(notificationMobilePushDevices)
.where(and(
eq(notificationMobilePushDevices.tenantId, item.tenantId),
eq(notificationMobilePushDevices.userId, item.userId),
isNull(notificationMobilePushDevices.disabledAt)
))
if (!subscriptions.length && !mobileDevices.length) {
await this.markFailed(item.id, "Keine aktive Push-Subscription")
return { success: false, id: item.id, channel: item.channel }
}
@@ -315,6 +317,37 @@ export class NotificationService {
let delivered = 0
const errors: string[] = []
if (mobileDevices.length) {
try {
const result = await pushServerClient.sendPush({
idempotencyKey: `notification:${item.id}`,
devices: mobileDevices.map((device) => device.centralDeviceId),
priority: "high",
ttlSeconds: 3600,
notification: {
title: item.title,
body: item.message,
},
data: {
notificationId: item.id,
...(typeof item.payload === "object" && item.payload !== null ? item.payload as Record<string, unknown> : {}),
},
})
delivered += result.accepted
if (result.rejected) errors.push(`${result.rejected} mobile Geräte vom Push-Server abgelehnt`)
} catch (error: any) {
errors.push(error?.message || String(error))
}
}
if (subscriptions.length) {
webPush.setVapidDetails(
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
secrets.WEB_PUSH_PUBLIC_KEY!,
secrets.WEB_PUSH_PRIVATE_KEY!
)
}
for (const subscription of subscriptions) {
try {
await webPush.sendNotification({

View File

@@ -0,0 +1,101 @@
import { createHash, createHmac } from "node:crypto"
import { secrets } from "../utils/secrets"
type PushServerDevicePlatform = "web" | "ios" | "android"
export type RegisterPushServerDeviceInput = {
localDeviceId: string
platform: PushServerDevicePlatform
providerToken?: string
subscription?: Record<string, unknown>
meta?: Record<string, unknown>
}
export type RegisterPushServerDeviceResult = {
centralDeviceId: string
status: string
}
export type SendPushServerMessageInput = {
idempotencyKey: string
devices: string[]
priority?: "normal" | "high"
ttlSeconds?: number
collapseKey?: string
notification?: {
title?: string
body?: string
}
data?: Record<string, unknown>
}
function configured() {
return Boolean(secrets.PUSH_SERVER_URL && secrets.PUSH_SERVER_INSTANCE_ID && secrets.PUSH_SERVER_SECRET)
}
function normalizeBaseUrl() {
return String(secrets.PUSH_SERVER_URL || "").replace(/\/+$/, "")
}
function bodyHash(body: string) {
return createHash("sha256").update(body).digest("hex")
}
function signature(method: string, path: string, timestamp: string, body: string) {
const canonical = [
method.toUpperCase(),
path,
timestamp,
bodyHash(body),
secrets.PUSH_SERVER_INSTANCE_ID,
].join("\n")
return createHmac("sha256", secrets.PUSH_SERVER_SECRET).update(canonical).digest("hex")
}
async function requestPushServer<T>(method: "POST" | "DELETE", path: string, payload?: unknown): Promise<T> {
if (!configured()) {
throw new Error("Zentraler Push-Server ist nicht konfiguriert")
}
const body = payload === undefined ? "" : JSON.stringify(payload)
const timestamp = new Date().toISOString()
const response = await fetch(`${normalizeBaseUrl()}${path}`, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Fedeo-Instance-Id": secrets.PUSH_SERVER_INSTANCE_ID,
"X-Fedeo-Timestamp": timestamp,
"X-Fedeo-Signature": signature(method, path, timestamp, body),
},
body: body || undefined,
})
const text = await response.text()
const data = text ? JSON.parse(text) : null
if (!response.ok) {
const message = data?.message || data?.error || `Push-Server Anfrage fehlgeschlagen (${response.status})`
throw new Error(message)
}
return data as T
}
export const pushServerClient = {
configured,
registerDevice(input: RegisterPushServerDeviceInput) {
return requestPushServer<RegisterPushServerDeviceResult>("POST", "/v1/devices", input)
},
sendPush(input: SendPushServerMessageInput) {
return requestPushServer<{ accepted: number; rejected: number; deliveryJobId: string }>("POST", "/v1/push", {
priority: "normal",
ttlSeconds: 3600,
...input,
})
},
}

View File

@@ -378,6 +378,7 @@ async function getSaveData(item: any, tenant: any, firstDate: string, lastDate:
contract: item.contract,
address: item.address,
project: item.project,
costcentre: item.costcentre,
documentDate: executionDate,
deliveryDate: firstDate,
deliveryDateEnd: lastDate,

View File

@@ -0,0 +1,174 @@
import { FastifyInstance } from "fastify"
import { matrixService } from "./matrix.service"
type MetricSample = {
labels: Record<string, string>
value: number
}
const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?|-?Inf|NaN)$/i
const nodeExporterUrl = () =>
(process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "")
const s3EndpointUrl = () =>
(process.env.S3_ENDPOINT || "").replace(/\/+$/, "")
const parseLabels = (value = "") => {
const labels: Record<string, string> = {}
const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g
let match: RegExpExecArray | null
while ((match = labelPattern.exec(value))) {
labels[match[1]] = match[2].replace(/\\"/g, "\"")
}
return labels
}
const parsePrometheusMetrics = (text: string) => {
const metrics = new Map<string, MetricSample[]>()
for (const line of text.split("\n")) {
if (!line || line.startsWith("#")) continue
const match = line.match(metricLinePattern)
if (!match) continue
const value = Number(match[3])
if (!Number.isFinite(value)) continue
const samples = metrics.get(match[1]) || []
samples.push({
labels: parseLabels(match[2]),
value,
})
metrics.set(match[1], samples)
}
return metrics
}
const firstMetricValue = (metrics: Map<string, MetricSample[]>, name: string) =>
metrics.get(name)?.[0]?.value ?? null
const findMetricValue = (
metrics: Map<string, MetricSample[]>,
name: string,
predicate: (sample: MetricSample) => boolean
) => metrics.get(name)?.find(predicate)?.value ?? null
const serviceState = (ok: boolean, detail?: Record<string, any>) => ({
ok,
status: ok ? "ok" : "error",
...detail,
})
const checkHttp = async (url: string, timeoutMs = 3000) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, { signal: controller.signal })
return serviceState(response.ok, {
httpStatus: response.status,
url,
})
} catch (err: any) {
return serviceState(false, {
url,
error: err?.message || "HTTP-Abfrage fehlgeschlagen",
})
} finally {
clearTimeout(timeout)
}
}
export const buildSystemStatus = async (server: FastifyInstance) => {
const checkedAt = new Date()
const nodeExporterMetricsUrl = `${nodeExporterUrl()}/metrics`
let nodeMetrics: Map<string, MetricSample[]> | null = null
let nodeExporterError: string | null = null
try {
const response = await fetch(nodeExporterMetricsUrl)
if (!response.ok) {
throw new Error(`Node Exporter antwortet mit ${response.status}`)
}
nodeMetrics = parsePrometheusMetrics(await response.text())
} catch (err: any) {
nodeExporterError = err?.message || "Node Exporter nicht erreichbar"
}
const memoryTotal = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemTotal_bytes") : null
const memoryAvailable = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemAvailable_bytes") : null
const rootSize = nodeMetrics
? findMetricValue(nodeMetrics, "node_filesystem_size_bytes", (sample) => sample.labels.mountpoint === "/")
: null
const rootAvailable = nodeMetrics
? findMetricValue(nodeMetrics, "node_filesystem_avail_bytes", (sample) => sample.labels.mountpoint === "/")
: null
const bootTime = nodeMetrics ? firstMetricValue(nodeMetrics, "node_boot_time_seconds") : null
const cpuCount = nodeMetrics
? new Set((nodeMetrics.get("node_cpu_seconds_total") || [])
.filter((sample) => sample.labels.mode === "idle")
.map((sample) => sample.labels.cpu)).size
: null
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
const databaseCheck = await server.db.execute("SELECT NOW() as now")
const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({
reachable: false,
error: err?.message || "Matrix-Status nicht verfügbar",
}))
const minioUrl = s3EndpointUrl()
return {
checkedAt: checkedAt.toISOString(),
backend: {
status: "ok",
uptimeSeconds: Math.round(process.uptime()),
nodeVersion: process.version,
environment: process.env.NODE_ENV || "development",
},
server: {
status: nodeMetrics ? "ok" : "unavailable",
nodeExporterUrl: nodeExporterMetricsUrl,
error: nodeExporterError,
hostname: uname?.nodename || null,
kernel: uname?.release || null,
cpuCount,
load: {
one: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load1") : null,
five: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load5") : null,
fifteen: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load15") : null,
},
memory: {
totalBytes: memoryTotal,
availableBytes: memoryAvailable,
usedBytes: memoryTotal !== null && memoryAvailable !== null ? memoryTotal - memoryAvailable : null,
usedPercent: memoryTotal ? Math.round(((memoryTotal - (memoryAvailable || 0)) / memoryTotal) * 1000) / 10 : null,
},
disk: {
rootTotalBytes: rootSize,
rootAvailableBytes: rootAvailable,
rootUsedBytes: rootSize !== null && rootAvailable !== null ? rootSize - rootAvailable : null,
rootUsedPercent: rootSize ? Math.round(((rootSize - (rootAvailable || 0)) / rootSize) * 1000) / 10 : null,
},
uptimeSeconds: bootTime ? Math.max(0, Math.round(Date.now() / 1000 - bootTime)) : null,
},
services: {
database: serviceState(true, {
checkedAt: String(databaseCheck.rows?.[0]?.now || checkedAt.toISOString()),
}),
nodeExporter: serviceState(Boolean(nodeMetrics), {
url: nodeExporterMetricsUrl,
error: nodeExporterError,
}),
matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record<string, any>),
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
error: "S3_ENDPOINT ist nicht gesetzt",
}),
},
}
}

View File

@@ -64,6 +64,19 @@ export default fp(async (server: FastifyInstance) => {
}
server.addHook("preHandler", async (req, reply) => {
if (req.method === "OPTIONS") {
return
}
const urlPath = req.url.split("?")[0]
const queryToken = (req.query as any)?.downloadToken
const downloadToken =
typeof queryToken === "string"
&& urlPath.startsWith("/api/email/attachments/")
&& urlPath.endsWith("/download")
? queryToken
: null
// 1⃣ Token aus Header oder Cookie lesen
const cookieToken = req.cookies?.token
const authHeader = req.headers.authorization
@@ -74,7 +87,7 @@ export default fp(async (server: FastifyInstance) => {
const token =
headerToken && headerToken.length > 10
? headerToken
: cookieToken || null
: cookieToken || downloadToken || null
if (!token) {
return reply.code(401).send({ error: "Authentication required" })

View File

@@ -15,6 +15,10 @@ import {
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";
import { buildSystemStatus } from "../modules/system-status.service";
import { matrixService } from "../modules/matrix.service";
export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => {
@@ -43,36 +47,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({
@@ -244,6 +254,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)
@@ -384,6 +395,21 @@ export default async function adminRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// GET /admin/system-status
// -------------------------------------------------------------
server.get("/admin/system-status", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
return await buildSystemStatus(server);
} catch (err) {
console.error("ERROR /admin/system-status:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// POST /admin/users
// -------------------------------------------------------------
@@ -929,6 +955,115 @@ 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,
});
}
let matrixProvisioned = false;
let matrixProvisioningError: string | null = null;
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
try {
const matrix = matrixService(server);
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
key: "allgemein",
name: "Allgemeiner Chat",
type: "general",
});
matrixProvisioned = true;
} catch (err: any) {
matrixProvisioningError = err?.message || String(err);
req.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
}
}
return {
success: true,
matrixProvisioned,
matrixProvisioningError,
...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

@@ -52,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
id: tenants.id,
name: tenants.name,
short: tenants.short,
calendarConfig: tenants.calendarConfig,
hasActiveLicense: tenants.hasActiveLicense,
locked: tenants.locked,
features: tenants.features,

View File

@@ -260,7 +260,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
}
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." })
const bookingDate = body.date && dayjs(body.date).isValid() ? dayjs(body.date) : dayjs()
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." })
@@ -273,8 +273,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
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 hasCounterInput = Boolean(body.counterType || body.counterId)
const counterPayload = hasCounterInput
? buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
: null
if (hasCounterInput && !counterPayload) return reply.code(400).send({ error: "Bitte ein gültiges Gegenkonto auswählen." })
const signedAmount = body.direction === "income"
? Math.abs(Number(body.amount))
@@ -284,7 +287,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
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"),
date: bookingDate.format("YYYY-MM-DD"),
valueDate: bookingDate.format("YYYY-MM-DD"),
amount: signedAmount,
tenant: req.user.tenant_id,
text: description,
@@ -295,18 +299,20 @@ export default async function bankingRoutes(server: FastifyInstance) {
}).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()
const insertedAllocations = counterPayload
? 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],
allocation: insertedAllocations[0] || null,
}
})
@@ -687,7 +693,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (matchesBankAccountId && matchesIban) {
score = 100
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
reason = "IBAN und hinterlegte Bankverbindung stimmen überein"
} else if (matchesBankAccountId) {
score = 95
reason = "Hinterlegte Bankverbindung passt zur IBAN"
@@ -699,7 +705,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
reason = "Name passt exakt zur Buchung"
} else if (partialNameMatch) {
score = 45
reason = "Name aehnelt der Buchung"
reason = "Name ähnelt der Buchung"
}
if (!score) continue
@@ -743,7 +749,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (matchesBankAccountId && matchesIban) {
score = 100
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
reason = "IBAN und hinterlegte Bankverbindung stimmen überein"
} else if (matchesBankAccountId) {
score = 95
reason = "Hinterlegte Bankverbindung passt zur IBAN"
@@ -755,7 +761,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
reason = "Name passt exakt zur Buchung"
} else if (partialNameMatch) {
score = 45
reason = "Name aehnelt der Buchung"
reason = "Name ähnelt der Buchung"
}
if (!score) continue

View File

@@ -1,7 +1,10 @@
import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify"
import { and, eq, ne } from "drizzle-orm"
import { authTenantUsers, authUsers } from "../../db/schema"
import multipart from "@fastify/multipart"
import { and, desc, eq, inArray, ne } from "drizzle-orm"
import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema"
import { matrixService } from "../modules/matrix.service"
import { getMatrixPushWorkerState } from "../modules/matrix-push-worker.service"
import { NotificationService, UserDirectory } from "../modules/notification.service"
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
@@ -14,7 +17,19 @@ const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId)
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) => {
@@ -47,6 +62,283 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
}
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 getTenantUser = async (tenantId: number, userId: string): Promise<ChatRecipient | null> => {
const [user] = 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),
eq(authTenantUsers.user_id, userId)
))
.limit(1)
return user || null
}
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)
} else if (room?.type === "direct" && room.key) {
recipients
.filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key)
.forEach((recipient) => directRecipients.add(recipient.userId))
}
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 hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => {
const rows = await server.db
.select({
payload: notificationsItems.payload,
})
.from(notificationsItems)
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
eq(notificationsItems.eventType, "communication.message.new")
))
.orderBy(desc(notificationsItems.createdAt))
.limit(200)
return rows.some((row) => (row.payload as any)?.messageId === messageId)
}
const notifyCurrentUserAboutIncomingMatrixMessages = async (req: any, room: any, messages: any[]) => {
if (!req.user.tenant_id || !messages.length) return
try {
const currentUser = await getTenantUser(req.user.tenant_id, req.user.user_id)
if (!currentUser) return
for (const message of messages) {
if (message.own || !message.id) continue
const text = message.body || message.attachment?.fileName || "Neue Nachricht"
const mentioned = mentionedRecipientIds(text, [currentUser]).includes(currentUser.userId)
const direct = room?.type === "direct"
if (!direct && !mentioned) continue
if (await hasChatNotificationForMessage(req.user.tenant_id, req.user.user_id, message.id)) continue
const senderName = message.senderDisplayName || message.sender || "Matrix"
const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text
await notifications.trigger({
tenantId: req.user.tenant_id,
userId: req.user.user_id,
eventType: "communication.message.new",
title: 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,
matrixSender: message.sender,
mentioned,
direct,
},
channels: ["inapp", "push"],
})
}
} catch (err) {
req.log.error({ err }, "Eingehende Matrix-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"
@@ -131,6 +423,233 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
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/push-worker", async () => {
return getMatrixPushWorkerState()
})
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(
@@ -178,6 +697,15 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
server.get("/communication/matrix/users", async (req, reply) => {
try {
const users = await matrix.listTenantCommunicationUsers(req.user.tenant_id)
return { users }
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix users failed")
}
})
server.post("/communication/matrix/rooms/general/session", async (req, reply) => {
try {
return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, {
@@ -216,13 +744,30 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
try {
const body = req.body as { text?: string }
return await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
const body = req.body as { text?: string; replyToEventId?: string }
const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "", {
replyToEventId: body.replyToEventId,
})
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 }
@@ -244,6 +789,21 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
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 }
const body = (req.body || {}) as { eventId?: string }
const result = await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey)
if (body.eventId) {
await matrix.markTenantRoomRead(req.user.user_id, req.user.tenant_id, roomOptionsFromRequest(req), body.eventId)
}
return result
} 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(
@@ -256,6 +816,41 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
server.get("/communication/matrix/rooms/:roomKey/search", async (req, reply) => {
try {
const query = req.query as { q?: string }
return await matrix.searchTenantRoomMessages(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
query.q || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix search failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/sync", async (req, reply) => {
try {
const query = req.query as { since?: string; initial?: string }
const result = await matrix.syncTenantRoomEvents(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
query.since,
query.initial === "1"
)
if (query.since && query.initial !== "1" && result.messages?.length) {
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, result.key, result.name)
await notifyCurrentUserAboutIncomingMatrixMessages(req, room, result.messages)
}
return result
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix sync failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
try {
return await matrix.getTenantRoomMembers(
@@ -268,6 +863,34 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
})
server.post("/communication/matrix/rooms/:roomKey/members/invite", async (req, reply) => {
try {
const body = req.body as { userId?: string }
return await matrix.inviteTenantRoomMember(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
body.userId || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member invite failed")
}
})
server.delete("/communication/matrix/rooms/:roomKey/members/:matrixUserId", async (req, reply) => {
try {
const params = req.params as { matrixUserId: string }
return await matrix.removeTenantRoomMember(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.matrixUserId
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member remove failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => {
try {
return await matrix.createElementRoomSession(
@@ -309,15 +932,84 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try {
const body = req.body as { text?: string }
return await matrix.sendTenantRoomMessage(
const body = req.body as { text?: string; replyToEventId?: string }
const message = await matrix.sendTenantRoomMessage(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
body.text || ""
body.text || "",
{
replyToEventId: body.replyToEventId,
}
)
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/messages/:eventId/reactions", async (req, reply) => {
try {
const params = req.params as { eventId: string }
const body = req.body as { key?: string }
return await matrix.sendTenantRoomReaction(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.eventId,
body.key || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix reaction send failed")
}
})
server.put("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => {
try {
const params = req.params as { eventId: string }
const body = req.body as { text?: string }
return await matrix.editTenantRoomMessage(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.eventId,
body.text || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message edit failed")
}
})
server.delete("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => {
try {
const params = req.params as { eventId: string }
return await matrix.redactTenantRoomMessage(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.eventId
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message delete 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

@@ -1,17 +1,72 @@
import nodemailer from "nodemailer"
import { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { and, eq } from "drizzle-orm"
import { sendMailAsUser } from "../utils/emailengine"
import { encrypt, decrypt } from "../utils/crypt"
import { userCredentials } from "../../db/schema"
// Pfad ggf. anpassen
import { emailSyncService } from "../modules/email/email.sync.service"
// @ts-ignore
import MailComposer from "nodemailer/lib/mail-composer/index.js"
import { ImapFlow } from "imapflow"
export default async function emailAsUserRoutes(server: FastifyInstance) {
const emailSync = emailSyncService(server)
const encryptedValue = (value: unknown) => value ? decrypt(value as any) : null
const accountResponse = (row: any) => ({
id: row.id,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
userId: row.userId,
tenantId: row.tenantId,
type: row.type,
email: encryptedValue(row.emailEncrypted),
smtpHost: encryptedValue(row.smtpHostEncrypted),
smtpPort: row.smtpPort ? Number(row.smtpPort) : null,
smtpSsl: row.smtpSsl,
imapHost: encryptedValue(row.imapHostEncrypted),
imapPort: row.imapPort ? Number(row.imapPort) : null,
imapSsl: row.imapSsl,
hasPassword: Boolean(row.passwordEncrypted),
})
const accountCredentials = (row: any) => ({
...accountResponse(row),
password: encryptedValue(row.passwordEncrypted),
})
const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey]
const applyDownloadCorsHeaders = (req: any, reply: any) => {
const origin = req.headers.origin
if (
origin
&& (
/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin)
|| origin === "https://beta.fedeo.de"
|| origin === "https://app.fedeo.de"
|| origin === "capacitor://localhost"
)
) {
reply.header("Access-Control-Allow-Origin", origin)
reply.header("Access-Control-Allow-Credentials", "true")
reply.header("Vary", "Origin")
}
reply.header("Access-Control-Expose-Headers", "Authorization, Content-Disposition, Content-Type, Content-Length")
}
const accountWhere = (tenantId: number, userId: string, id?: string) => {
const conditions = [
eq(userCredentials.tenantId, tenantId),
eq(userCredentials.userId, userId),
eq(userCredentials.type, "mail"),
]
if (id) conditions.push(eq(userCredentials.id, id))
return and(...conditions)
}
// ======================================================================
@@ -28,34 +83,49 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const body = req.body as {
email: string
password: string
smtp_host: string
smtp_port: number
smtp_ssl: boolean
imap_host: string
imap_port: number
imap_ssl: boolean
smtpHost?: string
smtpPort?: number
smtpSsl?: boolean
imapHost?: string
imapPort?: number
imapSsl?: boolean
smtp_host?: string
smtp_port?: number
smtp_ssl?: boolean
imap_host?: string
imap_port?: number
imap_ssl?: boolean
}
// -----------------------------
// UPDATE EXISTING
// -----------------------------
if (id) {
const rows = await server.db
.select({ id: userCredentials.id })
.from(userCredentials)
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
.limit(1)
if (!rows[0]) return reply.code(404).send({ error: "Account not found" })
const saveData = {
emailEncrypted: body.email ? encrypt(body.email) : undefined,
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
smtpPort: body.smtp_port,
smtpSsl: body.smtp_ssl,
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
imapPort: body.imap_port,
imapSsl: body.imap_ssl,
smtpHostEncrypted: bodyValue(body, "smtpHost", "smtp_host") ? encrypt(bodyValue(body, "smtpHost", "smtp_host")) : undefined,
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
imapHostEncrypted: bodyValue(body, "imapHost", "imap_host") ? encrypt(bodyValue(body, "imapHost", "imap_host")) : undefined,
imapPort: bodyValue(body, "imapPort", "imap_port"),
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
updatedAt: new Date(),
}
await server.db
.update(userCredentials)
//@ts-ignore
.set(saveData)
.where(eq(userCredentials.id, id))
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
return reply.send({ success: true })
}
@@ -71,13 +141,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
emailEncrypted: encrypt(body.email),
passwordEncrypted: encrypt(body.password),
smtpHostEncrypted: encrypt(body.smtp_host),
smtpPort: body.smtp_port,
smtpSsl: body.smtp_ssl,
smtpHostEncrypted: encrypt(bodyValue(body, "smtpHost", "smtp_host")),
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
imapHostEncrypted: encrypt(body.imap_host),
imapPort: body.imap_port,
imapSsl: body.imap_ssl,
imapHostEncrypted: encrypt(bodyValue(body, "imapHost", "imap_host")),
imapPort: bodyValue(body, "imapPort", "imap_port"),
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
}
//@ts-ignore
@@ -110,24 +180,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.id, id))
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
.limit(1)
const row = rows[0]
if (!row) return reply.code(404).send({ error: "Not found" })
const returnData: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted")) {
const cleanKey = key.replace("Encrypted", "")
// @ts-ignore
returnData[cleanKey] = decrypt(val as string)
} else {
returnData[key] = val
}
})
return reply.send(returnData)
return reply.send(accountResponse(row))
}
// ============================================================
@@ -136,24 +195,9 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.tenantId, req.user.tenant_id))
.where(accountWhere(req.user.tenant_id, req.user.user_id))
const accounts = rows.map(row => {
const temp: any = {}
console.log(row)
Object.entries(row).forEach(([key, val]) => {
console.log(key,val)
if (key.endsWith("Encrypted") && val) {
// @ts-ignore
temp[key.replace("Encrypted", "")] = decrypt(val)
} else {
temp[key] = val
}
})
return temp
})
return reply.send(accounts)
return reply.send(rows.map(accountResponse))
} catch (err) {
console.error("GET /email/accounts error:", err)
@@ -183,21 +227,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.id, body.account))
.where(accountWhere(req.user.tenant_id, req.user.user_id, body.account))
.limit(1)
const row = rows[0]
if (!row) return reply.code(404).send({ error: "Account not found" })
const accountData: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted") && val) {
// @ts-ignore
accountData[key.replace("Encrypted", "")] = decrypt(val as string)
} else {
accountData[key] = val
}
})
const accountData = accountCredentials(row)
// -------------------------
// SEND EMAIL VIA SMTP
@@ -243,14 +279,31 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const mail = new MailComposer(message)
const raw = await mail.compile().build()
let savedToSent = false
for await (const mailbox of await imap.list()) {
if (mailbox.specialUse === "\\Sent") {
await imap.mailboxOpen(mailbox.path)
await imap.append(mailbox.path, raw, ["\\Seen"])
await imap.logout()
savedToSent = true
break
}
}
if (!savedToSent) {
const sentFallbacks = ["Sent", "Gesendet", "INBOX.Sent"]
for (const path of sentFallbacks) {
try {
await imap.append(path, raw, ["\\Seen"])
savedToSent = true
break
} catch (err) {
// Fallback wird nur genutzt, wenn der Ordner existiert.
}
}
}
await imap.logout()
return reply.send({ success: true })
} catch (err) {
@@ -259,4 +312,195 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
}
})
server.post("/email/accounts/:id/sync", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const body = (req.body || {}) as { mailbox?: string; limit?: number }
const result = await emailSync.syncAccount(
req.user.tenant_id,
req.user.user_id,
id,
body,
)
return reply.send({ success: true, ...result })
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail Sync fehlgeschlagen" })
}
})
server.get("/email/accounts/:id/mailboxes", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
return reply.send(await emailSync.listMailboxes(req.user.tenant_id, req.user.user_id, id))
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "Postfächer konnten nicht geladen werden" })
}
})
server.get("/email/accounts/:id/messages", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const query = req.query as { mailbox?: string; limit?: string }
return reply.send(await emailSync.listMessages(
req.user.tenant_id,
req.user.user_id,
id,
query.mailbox || "INBOX",
Number(query.limit || 50),
))
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mails konnten nicht geladen werden" })
}
})
server.get("/email/messages/:id", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
return reply.send(message)
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht geladen werden" })
}
})
server.post("/email/messages/:id/read", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const body = (req.body || {}) as { seen?: boolean }
const message = await emailSync.setMessageSeen(
req.user.tenant_id,
req.user.user_id,
id,
body.seen !== false,
)
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
return reply.send({ success: true, message })
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "Lesestatus konnte nicht synchronisiert werden" })
}
})
server.post("/email/messages/:id/move", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const body = (req.body || {}) as { mailbox?: string }
if (!body.mailbox) {
return reply.code(400).send({ error: "Zielordner fehlt" })
}
const result = await emailSync.moveMessage(
req.user.tenant_id,
req.user.user_id,
id,
body.mailbox,
)
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
return reply.send({ success: true, ...result })
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht verschoben werden" })
}
})
server.post("/email/messages/:id/archive", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const result = await emailSync.archiveMessage(req.user.tenant_id, req.user.user_id, id)
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
return reply.send({ success: true, ...result })
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht archiviert werden" })
}
})
server.delete("/email/messages/:id", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const result = await emailSync.deleteMessage(req.user.tenant_id, req.user.user_id, id)
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
return reply.send({ success: true })
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht gelöscht werden" })
}
})
server.get("/email/attachments/:id/download", async (req, reply) => {
applyDownloadCorsHeaders(req, reply)
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
const attachment = await emailSync.getAttachmentContent(req.user.tenant_id, req.user.user_id, id)
if (!attachment) return reply.code(404).send({ error: "Anhang nicht gefunden" })
const buffer = Buffer.isBuffer(attachment.content)
? attachment.content
: Buffer.from(attachment.content)
const filename = attachment.filename.replace(/["\r\n]/g, "")
reply.header("Content-Type", attachment.contentType || "application/octet-stream")
reply.header("Content-Length", buffer.length)
reply.header("Cache-Control", "no-store")
reply.header("Content-Disposition", `attachment; filename="${filename}"`)
return reply.send(buffer)
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "Anhang konnte nicht geladen werden" })
}
})
}

View File

@@ -0,0 +1,249 @@
import { FastifyInstance, FastifyRequest } from "fastify"
import multipart from "@fastify/multipart"
import { createHash } from "node:crypto"
import { and, asc, eq, sql } from "drizzle-orm"
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
import { saveFile } from "../utils/files"
const hashToken = (token: string) =>
createHash("sha256").update(token, "utf8").digest("hex")
const readAgentToken = (req: FastifyRequest) => {
const headerToken = req.headers["x-agent-token"]
if (typeof headerToken === "string" && headerToken.length > 0) return headerToken
const authHeader = req.headers.authorization
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7)
return null
}
const pickFileTargets = (target: unknown) => {
if (!target || typeof target !== "object" || Array.isArray(target)) return {}
const allowedFields = [
"project",
"customer",
"contract",
"vendor",
"incominginvoice",
"plant",
"createddocument",
"vehicle",
"product",
"check",
"inventoryitem",
"space",
"documentbox",
"authProfile",
]
return Object.fromEntries(
Object.entries(target as Record<string, any>)
.filter(([key, value]) => allowedFields.includes(key) && value !== undefined && value !== null)
)
}
const readFileFolder = (target: unknown) => {
if (!target || typeof target !== "object" || Array.isArray(target)) return null
const folder = (target as Record<string, any>).folder
return typeof folder === "string" && folder.trim() ? folder.trim() : null
}
const readFileType = (target: unknown) => {
if (!target || typeof target !== "object" || Array.isArray(target)) return null
const type = (target as Record<string, any>).type
return typeof type === "string" && type.trim() ? type.trim() : null
}
export default async function instanceAgentGatewayRoutes(server: FastifyInstance) {
await server.register(multipart, {
limits: { fileSize: 100 * 1024 * 1024 },
})
const authenticateAgent = async (req: FastifyRequest, reply: any) => {
const token = readAgentToken(req)
if (!token) {
reply.code(401).send({ error: "Agent token required" })
return null
}
const [agent] = await server.db
.select()
.from(instanceAgents)
.where(and(
eq(instanceAgents.tokenHash, hashToken(token)),
eq(instanceAgents.active, true)
))
.limit(1)
if (!agent) {
reply.code(401).send({ error: "Invalid agent token" })
return null
}
return agent
}
server.post("/heartbeat", async (req, reply) => {
const agent = await authenticateAgent(req, reply)
if (!agent) return
const body = (req.body || {}) as {
capabilities?: Record<string, any>
scannerNames?: string[]
printerNames?: string[]
debugInfo?: Record<string, any>
}
await server.db
.update(instanceAgents)
.set({
capabilities: body.capabilities || agent.capabilities,
scannerNames: body.scannerNames || agent.scannerNames,
printerNames: body.printerNames || agent.printerNames,
lastDebugInfo: body.debugInfo || null,
lastSeenAt: new Date(),
updatedAt: new Date(),
})
.where(eq(instanceAgents.id, agent.id))
const [pending] = await server.db
.select({ count: sql<number>`count(*)::int` })
.from(instanceAgentScanJobs)
.where(and(
eq(instanceAgentScanJobs.agentId, agent.id),
eq(instanceAgentScanJobs.status, "pending")
))
return {
status: "ok",
pendingScanJobs: pending?.count || 0,
}
})
server.get("/scan-jobs/next", async (req, reply) => {
const agent = await authenticateAgent(req, reply)
if (!agent) return
const [pendingJob] = await server.db
.select()
.from(instanceAgentScanJobs)
.where(and(
eq(instanceAgentScanJobs.agentId, agent.id),
eq(instanceAgentScanJobs.status, "pending")
))
.orderBy(asc(instanceAgentScanJobs.createdAt))
.limit(1)
if (!pendingJob) return { job: null }
const [claimedJob] = await server.db
.update(instanceAgentScanJobs)
.set({
status: "running",
claimedAt: new Date(),
updatedAt: new Date(),
attempts: pendingJob.attempts + 1,
})
.where(and(
eq(instanceAgentScanJobs.id, pendingJob.id),
eq(instanceAgentScanJobs.status, "pending")
))
.returning()
return { job: claimedJob || null }
})
server.post<{ Params: { id: string } }>("/scan-jobs/:id/status", async (req, reply) => {
const agent = await authenticateAgent(req, reply)
if (!agent) return
const body = (req.body || {}) as { status?: string; message?: string }
const allowedStatuses = ["running", "failed", "canceled"]
if (!body.status || !allowedStatuses.includes(body.status)) {
return reply.code(400).send({ error: "Invalid status" })
}
const [job] = await server.db
.update(instanceAgentScanJobs)
.set({
status: body.status,
agentMessage: body.message,
finishedAt: ["failed", "canceled"].includes(body.status) ? new Date() : undefined,
updatedAt: new Date(),
})
.where(and(
eq(instanceAgentScanJobs.id, req.params.id),
eq(instanceAgentScanJobs.agentId, agent.id)
))
.returning()
if (!job) return reply.code(404).send({ error: "Scan job not found" })
return { job }
})
server.post<{ Params: { id: string } }>("/scan-jobs/:id/upload", async (req, reply) => {
const agent = await authenticateAgent(req, reply)
if (!agent) return
const [job] = await server.db
.select()
.from(instanceAgentScanJobs)
.where(and(
eq(instanceAgentScanJobs.id, req.params.id),
eq(instanceAgentScanJobs.agentId, agent.id)
))
.limit(1)
if (!job) return reply.code(404).send({ error: "Scan job not found" })
if (!["running", "pending"].includes(job.status)) {
return reply.code(409).send({ error: "Scan job is not uploadable" })
}
const data: any = await req.file()
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
const fileBuffer = await data.toBuffer()
const filename = job.requestedFilename || data.filename || `${job.id}.pdf`
const createdFile = await saveFile(
server,
job.tenantId,
null,
{
filename,
content: fileBuffer,
contentType: data.mimetype || "application/pdf",
},
readFileFolder(job.target),
readFileType(job.target),
{
...pickFileTargets(job.target),
createdBy: job.requestedBy,
}
)
if (!createdFile) return reply.code(500).send({ error: "Could not save scan file" })
const [updatedJob] = await server.db
.update(instanceAgentScanJobs)
.set({
status: "completed",
fileId: createdFile.id,
finishedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(instanceAgentScanJobs.id, job.id))
.returning()
return {
job: updatedJob,
file: createdFile,
}
})
}

View File

@@ -0,0 +1,209 @@
import { FastifyInstance } from "fastify"
import { createHash, randomBytes } from "node:crypto"
import { and, desc, eq } from "drizzle-orm"
import { z } from "zod"
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
const createAgentSchema = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
})
const updateAgentSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
active: z.boolean().optional(),
preferredScannerName: z.string().optional().nullable(),
scanDefaults: z.record(z.string(), z.any()).optional(),
})
const createScanJobSchema = z.object({
agentId: z.string().uuid(),
tenantId: z.number().int().positive().optional(),
scannerName: z.string().optional().nullable(),
requestedFilename: z.string().optional().nullable(),
settings: z.record(z.string(), z.any()).optional(),
target: z.record(z.string(), z.any()).optional(),
})
const hashToken = (token: string) =>
createHash("sha256").update(token, "utf8").digest("hex")
const createAgentToken = () => `fedeo_agent_${randomBytes(32).toString("hex")}`
const requireAdmin = (req: any, reply: any) => {
if (!req.user?.is_admin) {
reply.code(403).send({ error: "Admin required" })
return false
}
return true
}
export default async function instanceAgentRoutes(server: FastifyInstance) {
server.get("/instance-agents", async () => {
const rows = await server.db
.select({
id: instanceAgents.id,
createdAt: instanceAgents.createdAt,
updatedAt: instanceAgents.updatedAt,
name: instanceAgents.name,
description: instanceAgents.description,
tokenPrefix: instanceAgents.tokenPrefix,
active: instanceAgents.active,
capabilities: instanceAgents.capabilities,
scannerNames: instanceAgents.scannerNames,
printerNames: instanceAgents.printerNames,
preferredScannerName: instanceAgents.preferredScannerName,
scanDefaults: instanceAgents.scanDefaults,
lastSeenAt: instanceAgents.lastSeenAt,
lastDebugInfo: instanceAgents.lastDebugInfo,
})
.from(instanceAgents)
.orderBy(desc(instanceAgents.createdAt))
return { agents: rows }
})
server.post("/instance-agents", async (req, reply) => {
if (!requireAdmin(req, reply)) return
const body = createAgentSchema.parse(req.body)
const token = createAgentToken()
const [agent] = await server.db
.insert(instanceAgents)
.values({
name: body.name,
description: body.description,
tokenPrefix: token.slice(0, 24),
tokenHash: hashToken(token),
})
.returning({
id: instanceAgents.id,
name: instanceAgents.name,
description: instanceAgents.description,
tokenPrefix: instanceAgents.tokenPrefix,
active: instanceAgents.active,
preferredScannerName: instanceAgents.preferredScannerName,
scanDefaults: instanceAgents.scanDefaults,
createdAt: instanceAgents.createdAt,
})
return {
agent,
token,
}
})
server.patch<{ Params: { id: string } }>("/instance-agents/:id", async (req, reply) => {
if (!requireAdmin(req, reply)) return
const body = updateAgentSchema.parse(req.body)
const [agent] = await server.db
.update(instanceAgents)
.set({
...body,
updatedAt: new Date(),
})
.where(eq(instanceAgents.id, req.params.id))
.returning({
id: instanceAgents.id,
name: instanceAgents.name,
description: instanceAgents.description,
active: instanceAgents.active,
preferredScannerName: instanceAgents.preferredScannerName,
scanDefaults: instanceAgents.scanDefaults,
updatedAt: instanceAgents.updatedAt,
})
if (!agent) return reply.code(404).send({ error: "Agent not found" })
return { agent }
})
server.post("/scan-jobs", async (req, reply) => {
const body = createScanJobSchema.parse(req.body)
const requestedTenantId = body.tenantId || req.user?.tenant_id
if (!requestedTenantId) {
return reply.code(400).send({ error: "tenantId required" })
}
if (body.tenantId && body.tenantId !== req.user?.tenant_id && !req.user?.is_admin) {
return reply.code(403).send({ error: "Cannot create scan job for another tenant" })
}
const [agent] = await server.db
.select({
id: instanceAgents.id,
active: instanceAgents.active,
preferredScannerName: instanceAgents.preferredScannerName,
scanDefaults: instanceAgents.scanDefaults,
})
.from(instanceAgents)
.where(eq(instanceAgents.id, body.agentId))
.limit(1)
if (!agent || !agent.active) {
return reply.code(404).send({ error: "Active agent not found" })
}
const [job] = await server.db
.insert(instanceAgentScanJobs)
.values({
tenantId: requestedTenantId,
agentId: body.agentId,
requestedBy: req.user?.user_id,
scannerName: body.scannerName || agent.preferredScannerName,
requestedFilename: body.requestedFilename,
settings: {
...((agent.scanDefaults || {}) as Record<string, any>),
...(body.settings || {}),
},
target: body.target || {},
})
.returning()
return { job }
})
server.get("/scan-jobs", async (req) => {
const query = req.query as { tenantId?: string }
const tenantId = req.user?.is_admin && query.tenantId
? Number(query.tenantId)
: req.user?.tenant_id
const rows = tenantId
? await server.db
.select()
.from(instanceAgentScanJobs)
.where(eq(instanceAgentScanJobs.tenantId, tenantId))
.orderBy(desc(instanceAgentScanJobs.createdAt))
: await server.db
.select()
.from(instanceAgentScanJobs)
.orderBy(desc(instanceAgentScanJobs.createdAt))
return { jobs: rows }
})
server.get<{ Params: { id: string } }>("/scan-jobs/:id", async (req, reply) => {
const conditions = [eq(instanceAgentScanJobs.id, req.params.id)]
if (!req.user?.is_admin) {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "tenant required" })
conditions.push(eq(instanceAgentScanJobs.tenantId, req.user.tenant_id))
}
const [job] = await server.db
.select()
.from(instanceAgentScanJobs)
.where(and(...conditions))
.limit(1)
if (!job) return reply.code(404).send({ error: "Scan job not found" })
return { job }
})
}

View File

@@ -1,7 +1,8 @@
import { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { authUsers } from "../../db/schema"
import { and, eq, isNull } from "drizzle-orm"
import { authUsers, notificationMobilePushDevices } from "../../db/schema"
import { NotificationService, UserDirectory } from "../modules/notification.service"
import { pushServerClient } from "../modules/push-server.client"
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
const rows = await server.db
@@ -60,6 +61,104 @@ export default async function notificationsRoutes(server: FastifyInstance) {
return await svc.disablePushSubscription(requireTenant(req.user.tenant_id), req.user.user_id, body.endpoint)
})
server.post("/notifications/push/mobile/register", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const body = (req.body || {}) as {
localDeviceId?: string
platform?: "ios" | "android"
providerToken?: string
deviceLabel?: string
meta?: Record<string, unknown>
}
if (!body.localDeviceId) throw new Error("localDeviceId fehlt")
if (body.platform !== "ios" && body.platform !== "android") throw new Error("platform ist ungültig")
if (!body.providerToken) throw new Error("providerToken fehlt")
const centralLocalDeviceId = `${tenantId}:${req.user.user_id}:${body.localDeviceId}`
const registered = await pushServerClient.registerDevice({
localDeviceId: centralLocalDeviceId,
platform: body.platform,
providerToken: body.providerToken,
meta: {
...(body.meta || {}),
tenantId,
userId: req.user.user_id,
source: "fedeo-mobile",
},
})
const rows = await server.db
.insert(notificationMobilePushDevices)
.values({
tenantId,
userId: req.user.user_id,
localDeviceId: body.localDeviceId,
centralDeviceId: registered.centralDeviceId,
platform: body.platform,
providerTokenPreview: previewToken(body.providerToken),
deviceLabel: body.deviceLabel,
meta: body.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
})
.onConflictDoUpdate({
target: [
notificationMobilePushDevices.tenantId,
notificationMobilePushDevices.userId,
notificationMobilePushDevices.localDeviceId,
],
set: {
centralDeviceId: registered.centralDeviceId,
platform: body.platform,
providerTokenPreview: previewToken(body.providerToken),
deviceLabel: body.deviceLabel,
meta: body.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
},
})
.returning()
return {
success: true,
id: rows[0]?.id,
centralDeviceId: registered.centralDeviceId,
status: registered.status,
}
})
server.post("/notifications/test-mobile-push", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const devices = await server.db
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
.from(notificationMobilePushDevices)
.where(and(
eq(notificationMobilePushDevices.tenantId, tenantId),
eq(notificationMobilePushDevices.userId, req.user.user_id),
isNull(notificationMobilePushDevices.disabledAt)
))
if (!devices.length) {
throw new Error("Kein registriertes mobiles Push-Gerät gefunden")
}
return await pushServerClient.sendPush({
idempotencyKey: `mobile-test:${tenantId}:${req.user.user_id}:${Date.now()}`,
devices: devices.map((device) => device.centralDeviceId),
priority: "high",
ttlSeconds: 600,
notification: {
title: "FEDEO Mobile Push ist aktiv",
body: "Diese Testnachricht wurde über den zentralen Push-Server zugestellt.",
},
data: {
type: "system.test_mobile_push",
link: "/",
},
})
})
server.post("/notifications/test-push", async (req) => {
return await svc.trigger({
tenantId: requireTenant(req.user.tenant_id),
@@ -90,3 +189,8 @@ export default async function notificationsRoutes(server: FastifyInstance) {
}
})
}
function previewToken(token: string) {
if (token.length <= 14) return token
return `${token.slice(0, 6)}...${token.slice(-6)}`
}

View File

@@ -14,6 +14,10 @@ import {
resolveTenantTeamIds,
syncProfileTeams,
} from "../utils/profileTeams";
import {
enrichProfileWithCalendarSubscription,
generateProfileCalendarSubscriptionToken,
} from "../utils/calendarSubscription";
export default async function authProfilesRoutes(server: FastifyInstance) {
@@ -38,7 +42,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "User not found or not in tenant" });
}
return profile;
return enrichProfileWithCalendarSubscription(profile);
} catch (error) {
console.error("GET /profiles/:id ERROR:", error);
@@ -50,10 +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",
"branch"
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])
@@ -146,7 +151,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
const [profile] = profileWithBranches
? await enrichProfilesWithTeams(server, [profileWithBranches])
: [null]
return profile || updated[0]
return enrichProfileWithCalendarSubscription(profile || updated[0])
} catch (err) {
console.error("PUT /profiles/:id ERROR:", err)
@@ -159,4 +164,31 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
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

@@ -230,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 = {
@@ -324,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(),
}
}
@@ -865,6 +924,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
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)
@@ -873,7 +936,16 @@ 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()
@@ -956,6 +1028,14 @@ export default async function resourceRoutes(server: FastifyInstance) {
//@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),
@@ -1004,6 +1084,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "createddocuments") {
data = normalizeCreatedDocumentPayload(data)
}
Object.keys(data).forEach((key) => {
const value = data[key]
const shouldNormalize =

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,21 @@ export default async function tenantRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// GET CURRENT TENANT
// -------------------------------------------------------------
server.get("/tenant", async (req) => {
server.get("/tenant", async (req, reply) => {
if (req.user?.tenant_id) {
const tenantRows = await server.db
.select()
.from(tenants)
.where(eq(tenants.id, Number(req.user.tenant_id)))
.limit(1)
if (!tenantRows.length) {
return reply.code(404).send({ error: "Tenant not found" })
}
return tenantRows[0]
}
if (req.tenant) {
return {
message: `Hallo vom Tenant ${req.tenant?.name}`,

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

@@ -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

@@ -78,7 +78,7 @@ export const resourceConfig = {
table: contracts,
searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
numberRangeHolder: "contractNumber",
mtoLoad: ["customer", "contracttype", "outgoingsepamandate"],
mtoLoad: ["customer", "contracttype", "contact", "outgoingsepamandate"],
},
outgoingsepamandates: {
table: outgoingsepamandates,
@@ -105,7 +105,22 @@ export const resourceConfig = {
numberRangeHolder: "vendorNumber",
},
files: {
table: files
table: files,
mtoLoad: [
"project",
"customer",
"contract",
"vendor",
"incominginvoice",
"plant",
"createddocument",
"vehicle",
"product",
"check",
"inventoryitem",
"authProfile",
"type",
],
},
folders: {
table: folders
@@ -113,6 +128,9 @@ export const resourceConfig = {
filetags: {
table: filetags
},
type: {
table: filetags
},
inventoryitems: {
table: inventoryitems,
numberRangeHolder: "articleNumber",
@@ -201,13 +219,18 @@ export const resourceConfig = {
tenantKey: "tenant_id",
searchColumns: ["first_name", "last_name", "full_name", "email", "employee_number"],
},
authProfile: {
table: authProfiles,
tenantKey: "tenant_id",
searchColumns: ["first_name", "last_name", "full_name", "email", "employee_number"],
},
letterheads: {
table: letterheads,
},
createddocuments: {
table: createddocuments,
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument", "outgoingsepamandate"],
mtoLoad: ["customer", "project", "costcentre", "contact", "contract", "plant","letterhead","createddocument", "outgoingsepamandate"],
mtmLoad: ["statementallocations","files","createddocuments"],
mtmListLoad: ["statementallocations", "files"],
},

View File

@@ -50,6 +50,9 @@ export let secrets = {
WEB_PUSH_PUBLIC_KEY?: string
WEB_PUSH_PRIVATE_KEY?: string
WEB_PUSH_SUBJECT?: string
PUSH_SERVER_URL?: string
PUSH_SERVER_INSTANCE_ID?: string
PUSH_SERVER_SECRET?: string
}
const secretKeys = [
@@ -94,6 +97,9 @@ const secretKeys = [
"WEB_PUSH_PUBLIC_KEY",
"WEB_PUSH_PRIVATE_KEY",
"WEB_PUSH_SUBJECT",
"PUSH_SERVER_URL",
"PUSH_SERVER_INSTANCE_ID",
"PUSH_SERVER_SECRET",
] as const
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])

View File

@@ -0,0 +1,646 @@
import { FastifyInstance } from "fastify"
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { pool } from "../../db"
import { s3 } from "./s3"
import { secrets } from "./secrets"
import { decrypt, encrypt } from "./crypt"
type TableRows = Record<string, Record<string, any>[]>
type TableMetadata = {
columns: string[]
jsonColumns: Set<string>
generatedColumns: Set<string>
}
export type TenantFullExport = {
format: "fedeo.tenant-full-export"
version: 1
exportedAt: string
tenantId: number
tables: TableRows
files: {
id: string
path: string
name: string | null
mimeType: string | null
size: number | null
contentBase64: string | null
missing?: boolean
error?: string
}[]
}
type ImportResult = {
tenantId: number
tables: { table: string; rows: number }[]
files: { restored: number; skipped: number }
}
type ImportOptions = {
targetTenantId?: number | null
}
const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
iban: "__plainIban",
bic: "__plainBic",
bankName: "__plainBankName",
}
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
const matrixServerName = () =>
process.env.MATRIX_SERVER_NAME ||
secrets.MATRIX_SERVER_NAME ||
process.env.DOMAIN ||
"localhost"
const normalizeMatrixLocalpartSeed = (value: string) => {
const normalized = value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ä/g, "a")
.replace(/ö/g, "o")
.replace(/ü/g, "u")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9._=-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._=-]+|[._=-]+$/g, "")
return normalized || "user"
}
const normalizeMatrixAliasSeed = (value: string) =>
normalizeMatrixLocalpartSeed(value)
.replace(/[.=]/g, "_")
.replace(/_+/g, "_")
const tenantRoomAliasLocalpart = (
tenant: { id: number, short?: string | null, name?: string | null },
roomKey: string
) => {
const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`)
const roomSeed = normalizeMatrixAliasSeed(roomKey)
return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}`
}
const tenantRoomAlias = (
tenant: { id: number, short?: string | null, name?: string | null },
roomKey: string
) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${matrixServerName()}`
const tableColumns = async (client: any) => {
const result = await client.query(`
select table_name, column_name, data_type, is_generated
from information_schema.columns
where table_schema = 'public'
order by table_name, ordinal_position
`)
const columnsByTable = new Map<string, TableMetadata>()
for (const row of result.rows) {
const metadata = columnsByTable.get(row.table_name) || {
columns: [],
jsonColumns: new Set<string>(),
generatedColumns: new Set<string>(),
}
metadata.columns.push(row.column_name)
if (row.data_type === "json" || row.data_type === "jsonb") {
metadata.jsonColumns.add(row.column_name)
}
if (row.is_generated === "ALWAYS") {
metadata.generatedColumns.add(row.column_name)
}
columnsByTable.set(row.table_name, metadata)
}
return columnsByTable
}
const loadRows = async (client: any, table: string, whereSql: string, params: any[] = []) => {
const result = await client.query(`select * from ${quoteIdent(table)} where ${whereSql}`, params)
return result.rows
}
const collectIds = (rows: Record<string, any>[], column: string) => {
return Array.from(new Set(rows.map((row) => row[column]).filter(Boolean)))
}
const addRows = (tables: TableRows, table: string, rows: Record<string, any>[]) => {
if (!rows.length) {
if (!tables[table]) tables[table] = []
return
}
const existingRows = tables[table] || []
const existingKeys = new Set(existingRows.map((row) => JSON.stringify(row)))
for (const row of rows) {
const key = JSON.stringify(row)
if (!existingKeys.has(key)) {
existingRows.push(row)
existingKeys.add(key)
}
}
tables[table] = existingRows
}
const decryptEntityBankAccountsForExport = (rows: Record<string, any>[] = []) => {
return rows.map((row) => {
const nextRow = { ...row }
try {
nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban] = row.iban_encrypted ? decrypt(row.iban_encrypted) : null
nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic] = row.bic_encrypted ? decrypt(row.bic_encrypted) : null
nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName] = row.bank_name_encrypted ? decrypt(row.bank_name_encrypted) : null
} catch (err: any) {
throw new Error(`Bankverbindung ${row.id || ""} konnte für den Export nicht entschlüsselt werden: ${err?.message || err}`)
}
return nextRow
})
}
const isMissingObjectError = (err: any) =>
err?.Code === "NoSuchKey" ||
err?.name === "NoSuchKey" ||
err?.$metadata?.httpStatusCode === 404
const loadObjectAsBase64 = async (path: string) => {
try {
const { Body } = await s3.send(new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: path,
}))
const chunks: Buffer[] = []
for await (const chunk of Body as any) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return {
contentBase64: Buffer.concat(chunks).toString("base64"),
missing: false,
error: null,
}
} catch (err: any) {
if (!isMissingObjectError(err)) throw err
return {
contentBase64: null,
missing: true,
error: err?.Code || err?.name || "NoSuchKey",
}
}
}
export const buildTenantFullExport = async (server: FastifyInstance, tenantId: number): Promise<TenantFullExport> => {
const client = await pool.connect()
try {
const columnsByTable = await tableColumns(client)
const tables: TableRows = {}
const tenantRows = await loadRows(client, "tenants", "id = $1", [tenantId])
if (!tenantRows.length) throw new Error("Tenant nicht gefunden")
addRows(tables, "tenants", tenantRows)
for (const [table, metadata] of columnsByTable.entries()) {
if (table === "tenants") continue
const { columns } = metadata
const tenantColumn = columns.includes("tenant")
? "tenant"
: columns.includes("tenant_id")
? "tenant_id"
: null
if (!tenantColumn) continue
const rows = await loadRows(client, table, `${quoteIdent(tenantColumn)} = $1`, [tenantId])
addRows(tables, table, rows)
}
const profileIds = collectIds(tables.auth_profiles || [], "id")
const userIds = Array.from(new Set([
...collectIds(tables.auth_tenant_users || [], "user_id"),
...collectIds(tables.auth_profiles || [], "user_id"),
...collectIds(tables.auth_user_roles || [], "user_id"),
]))
const roleIds = Array.from(new Set([
...collectIds(tables.auth_roles || [], "id"),
...collectIds(tables.auth_user_roles || [], "role_id"),
]))
if (userIds.length) {
addRows(tables, "auth_users", await loadRows(client, "auth_users", "id = any($1::uuid[])", [userIds]))
}
if (roleIds.length) {
addRows(tables, "auth_roles", await loadRows(client, "auth_roles", "id = any($1::uuid[])", [roleIds]))
addRows(tables, "auth_role_permissions", await loadRows(client, "auth_role_permissions", "role_id = any($1::uuid[])", [roleIds]))
}
if (profileIds.length) {
addRows(tables, "auth_profile_branches", await loadRows(client, "auth_profile_branches", "profile_id = any($1::uuid[])", [profileIds]))
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
}
if (tables.entitybankaccounts?.length) {
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
}
const fileRows = tables.files || []
const files = []
for (const file of fileRows) {
if (!file.path) continue
const object = await loadObjectAsBase64(file.path)
if (object.missing) {
server.log.warn({
fileId: file.id,
path: file.path,
bucket: secrets.S3_BUCKET,
error: object.error,
}, "Tenant full export skipped missing S3 object")
}
files.push({
id: file.id,
path: file.path,
name: file.name || null,
mimeType: file.mimeType || null,
size: file.size || null,
contentBase64: object.contentBase64,
missing: object.missing || undefined,
error: object.error || undefined,
})
}
return {
format: "fedeo.tenant-full-export",
version: 1,
exportedAt: new Date().toISOString(),
tenantId,
tables,
files,
}
} finally {
client.release()
}
}
const restoreFiles = async (exportData: TenantFullExport) => {
let restored = 0
let skipped = 0
for (const file of exportData.files || []) {
if (!file.path || !file.contentBase64) {
skipped += 1
continue
}
const body = Buffer.from(file.contentBase64, "base64")
await s3.send(new PutObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: file.path,
Body: body,
ContentType: file.mimeType || "application/octet-stream",
ContentLength: body.length,
}))
restored += 1
}
return { restored, skipped }
}
const remapTenantScopedExport = (
exportData: TenantFullExport,
targetTenantId?: number | null
): TenantFullExport => {
if (!targetTenantId || targetTenantId === exportData.tenantId) return exportData
const sourceTenantId = exportData.tenantId
const sourcePathPrefix = `${sourceTenantId}/`
const targetPathPrefix = `${targetTenantId}/`
const tables: TableRows = {}
for (const [table, rows] of Object.entries(exportData.tables || {})) {
tables[table] = rows.map((row) => {
const nextRow = { ...row }
if (table === "tenants" && nextRow.id === sourceTenantId) {
nextRow.id = targetTenantId
}
if (nextRow.tenant === sourceTenantId) {
nextRow.tenant = targetTenantId
}
if (nextRow.tenant_id === sourceTenantId) {
nextRow.tenant_id = targetTenantId
}
if (table === "files" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) {
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
}
return nextRow
})
}
return {
...exportData,
tenantId: targetTenantId,
tables,
files: (exportData.files || []).map((file) => ({
...file,
path: file.path?.startsWith(sourcePathPrefix)
? `${targetPathPrefix}${file.path.slice(sourcePathPrefix.length)}`
: file.path,
})),
}
}
const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) => {
const rows = exportData.tables.entitybankaccounts || []
for (const row of rows) {
const plainIban = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban]
const plainBic = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic]
const plainBankName = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName]
if (typeof plainIban === "string" && typeof plainBic === "string" && typeof plainBankName === "string") {
row.iban_encrypted = encrypt(plainIban)
row.bic_encrypted = encrypt(plainBic)
row.bank_name_encrypted = encrypt(plainBankName)
}
delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban]
delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic]
delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName]
}
}
const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => {
const rows = exportData.tables.communication_rooms || []
if (!rows.length) return
const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [
Number(tenant.id),
{
id: Number(tenant.id),
name: tenant.name,
short: tenant.short,
},
]))
for (const row of rows) {
const tenantId = Number(row.tenant_id)
const tenant = tenantById.get(tenantId)
row.matrix_room_id = null
row.parent_space_room_id = null
if (tenant && row.key) {
row.matrix_alias = tenantRoomAlias(tenant, String(row.key))
} else {
row.matrix_alias = null
}
}
}
const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => {
const rows = exportData.tables.communication_rooms || []
if (!rows.length) return 0
const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [
Number(tenant.id),
{
id: Number(tenant.id),
name: tenant.name,
short: tenant.short,
},
]))
let cleaned = 0
for (const row of rows) {
const tenantId = Number(row.tenant_id)
const key = String(row.key || "")
const tenant = tenantById.get(tenantId)
if (!tenantId || !key || !tenant) continue
const alias = tenantRoomAlias(tenant, key)
const result = await client.query(
`
update communication_rooms
set matrix_room_id = null,
parent_space_room_id = null,
matrix_alias = $3,
updated_at = now()
where tenant_id = $1 and key = $2
`,
[tenantId, key, alias]
)
cleaned += result.rowCount || 0
}
return cleaned
}
const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
if (!isJsonColumn || value === null || typeof value === "undefined") return value
if (typeof value === "string") return value
return JSON.stringify(value)
}
const insertRows = async (client: any, table: string, rows: Record<string, any>[], metadata: TableMetadata) => {
if (!rows.length) return 0
let inserted = 0
const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column)))
for (const row of rows) {
const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column))
if (!rowColumns.length) continue
const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
const values = rowColumns.map((column) => prepareColumnValue(row[column], metadata.jsonColumns.has(column)))
await client.query(
`insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) on conflict do nothing`,
values
)
inserted += 1
}
return inserted
}
const insertIdentityRowsWithRemap = async (
client: any,
table: string,
rows: Record<string, any>[],
metadata: TableMetadata
) => {
if (!rows.length) return { count: 0, idMap: new Map<number, number>() }
let count = 0
const idMap = new Map<number, number>()
const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column)))
for (const row of rows) {
const sourceId = Number(row.id)
if (!sourceId) continue
const existing = await client.query(`select * from ${quoteIdent(table)} where ${quoteIdent("id")} = $1 limit 1`, [sourceId])
const existingTenant = existing.rows[0]?.tenant ?? existing.rows[0]?.tenant_id
const rowTenant = row.tenant ?? row.tenant_id
const shouldPreserveId = existing.rows.length === 0
const shouldReuseExisting = existing.rows.length > 0 && rowTenant && existingTenant === rowTenant
const rowForInsert = shouldPreserveId ? row : { ...row, id: undefined }
const rowColumns = Object
.keys(rowForInsert)
.filter((column) => availableColumns.has(column) && typeof rowForInsert[column] !== "undefined")
if (shouldReuseExisting) {
idMap.set(sourceId, sourceId)
continue
}
if (!rowColumns.length) continue
const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
const values = rowColumns.map((column) => prepareColumnValue(rowForInsert[column], metadata.jsonColumns.has(column)))
const inserted = await client.query(
`insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) returning ${quoteIdent("id")}`,
values
)
const targetId = Number(inserted.rows[0]?.id)
if (targetId) {
idMap.set(sourceId, targetId)
}
count += 1
}
return { count, idMap }
}
const remapTableColumn = (rows: Record<string, any>[] = [], column: string, idMap: Map<number, number>) => {
if (!idMap.size) return
for (const row of rows) {
const currentValue = Number(row[column])
const mappedValue = idMap.get(currentValue)
if (mappedValue) row[column] = mappedValue
}
}
const refreshSequences = async (client: any, columnsByTable: Map<string, TableMetadata>) => {
for (const [table, metadata] of columnsByTable.entries()) {
const { columns } = metadata
if (!columns.includes("id")) continue
const sequenceResult = await client.query("select pg_get_serial_sequence($1, $2) as sequence_name", [`public.${table}`, "id"])
const sequenceName = sequenceResult.rows[0]?.sequence_name
if (!sequenceName) continue
await client.query(`
select setval(
$1::regclass,
greatest(coalesce((select max(id) from ${quoteIdent(table)}), 1), 1),
true
)
`, [sequenceName])
}
}
export const importTenantFullExport = async (
server: FastifyInstance,
rawExportData: TenantFullExport,
options: ImportOptions = {}
): Promise<ImportResult> => {
if (rawExportData?.format !== "fedeo.tenant-full-export" || rawExportData.version !== 1) {
throw new Error("Ungültiges FEDEO Mandantenexport-Format")
}
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
encryptEntityBankAccountRowsForImport(exportData)
prepareCommunicationRoomsForImport(exportData)
const client = await pool.connect()
const importOrder = [
"tenants",
"auth_users",
"auth_roles",
"auth_role_permissions",
"auth_tenant_users",
"auth_profiles",
"auth_user_roles",
"auth_profile_branches",
"auth_profile_teams",
]
try {
const columnsByTable = await tableColumns(client)
const tableNames = [
...importOrder,
...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
].filter((table, index, all) => all.indexOf(table) === index)
await client.query("begin")
await client.query("set local session_replication_role = replica")
const files = await restoreFiles(exportData)
const importedTables: { table: string; rows: number }[] = []
const bankaccountMetadata = columnsByTable.get("bankaccounts")
if (bankaccountMetadata) {
const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata)
remapTableColumn(exportData.tables.bankstatements, "account", result.idMap)
importedTables.push({ table: "bankaccounts", rows: result.count })
}
const bankstatementMetadata = columnsByTable.get("bankstatements")
if (bankstatementMetadata) {
const result = await insertIdentityRowsWithRemap(client, "bankstatements", exportData.tables.bankstatements || [], bankstatementMetadata)
remapTableColumn(exportData.tables.statementallocations, "bs_id", result.idMap)
remapTableColumn(exportData.tables.historyitems, "bankstatement", result.idMap)
importedTables.push({ table: "bankstatements", rows: result.count })
}
for (const table of tableNames) {
if (["bankaccounts", "bankstatements"].includes(table)) continue
const rows = exportData.tables[table] || []
const metadata = columnsByTable.get(table)
if (!metadata) continue
const count = await insertRows(client, table, rows, metadata)
importedTables.push({ table, rows: count })
}
const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData)
if (cleanedCommunicationRooms) {
importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms })
}
await refreshSequences(client, columnsByTable)
await client.query("commit")
return {
tenantId: exportData.tenantId,
tables: importedTables,
files,
}
} catch (err) {
await client.query("rollback")
throw err
} finally {
client.release()
}
}

View File

@@ -81,8 +81,7 @@ services:
- internal
backend:
build:
context: ./backend
image: git.federspiel.tech/flfeders/fedeo/backend:dev
container_name: fedeo-backend
restart: unless-stopped
depends_on:
@@ -92,6 +91,8 @@ services:
condition: service_healthy
createbuckets:
condition: service_completed_successfully
matrix-synapse:
condition: service_healthy
environment:
NODE_ENV: production
FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true}
@@ -100,7 +101,7 @@ services:
COOKIE_SECRET: ${COOKIE_SECRET}
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
DATABASE_URL: ${DATABASE_URL}
DATABASE_URL: ${DATABASE_URL:-postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}}
MAILER_SMTP_HOST: ${MAILER_SMTP_HOST}
MAILER_SMTP_PORT: ${MAILER_SMTP_PORT}
MAILER_SMTP_SSL: ${MAILER_SMTP_SSL}
@@ -112,6 +113,11 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET}
FEDEO_FILE_BACKEND: ${FEDEO_FILE_BACKEND:-s3}
SEAFILE_BASE_URL: ${SEAFILE_BASE_URL}
SEAFILE_INTERNAL_URL: ${SEAFILE_INTERNAL_URL}
SEAFILE_ADMIN_EMAIL: ${SEAFILE_ADMIN_EMAIL}
SEAFILE_ADMIN_PASSWORD: ${SEAFILE_ADMIN_PASSWORD}
M2M_API_KEY: ${M2M_API_KEY}
API_BASE_URL: ${API_BASE_URL}
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
@@ -130,6 +136,17 @@ services:
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer}
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
FEDEO_BOOTSTRAP_MATRIX: ${FEDEO_BOOTSTRAP_MATRIX:-true}
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://matrix-synapse:8008}
MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}}
MATRIX_RTC_HOST: ${MATRIX_RTC_HOST:-${DOMAIN}}
MATRIX_RTC_JWT_URL: ${MATRIX_RTC_JWT_URL:-}
MATRIX_LIVEKIT_URL: ${MATRIX_LIVEKIT_URL:-}
MATRIX_REGISTRATION_SHARED_SECRET: ${MATRIX_REGISTRATION_SHARED_SECRET:-change-this-matrix-registration-secret}
MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service}
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
@@ -138,13 +155,30 @@ 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
node-exporter:
image: prom/node-exporter:v1.8.2
container_name: fedeo-node-exporter
restart: unless-stopped
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro,rslave
networks:
- internal
frontend:
build:
context: ./frontend
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
container_name: fedeo-frontend
restart: unless-stopped
depends_on:
@@ -159,12 +193,316 @@ services:
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-frontend.priority=1
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
- traefik.docker.network=fedeo_web
networks:
- web
matrix-db:
image: postgres:16-alpine
container_name: fedeo-matrix-db
restart: unless-stopped
environment:
POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse}
POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse}
POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password}
POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C
volumes:
- ./matrix/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${MATRIX_POSTGRES_USER:-synapse} -d ${MATRIX_POSTGRES_DB:-synapse}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- internal
matrix-redis:
image: redis:7-alpine
container_name: fedeo-matrix-redis
restart: unless-stopped
networks:
- internal
matrix-synapse:
image: ghcr.io/element-hq/synapse:latest
container_name: fedeo-matrix-synapse
restart: unless-stopped
depends_on:
matrix-db:
condition: service_healthy
matrix-redis:
condition: service_started
environment:
DOMAIN: ${DOMAIN}
MATRIX_POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse}
MATRIX_POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse}
MATRIX_POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password}
MATRIX_REGISTRATION_SHARED_SECRET: ${MATRIX_REGISTRATION_SHARED_SECRET:-change-this-matrix-registration-secret}
MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}}
MATRIX_TURN_SHARED_SECRET: ${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret}
SYNAPSE_CONFIG_PATH: /data/homeserver.yaml
SYNAPSE_REPORT_STATS: "no"
SYNAPSE_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}}
entrypoint: /bin/sh
command:
- -ec
- |
if [ ! -f /data/homeserver.yaml ]; then
/start.py generate
fi
python - <<'PY'
import os
import yaml
path = "/data/homeserver.yaml"
with open(path, "r", encoding="utf-8") as handle:
config = yaml.safe_load(handle) or {}
domain = os.environ["DOMAIN"]
server_name = os.environ.get("MATRIX_SERVER_NAME") or domain
config["server_name"] = server_name
config["public_baseurl"] = f"https://{domain}/"
config["database"] = {
"name": "psycopg2",
"args": {
"user": os.environ.get("MATRIX_POSTGRES_USER", "synapse"),
"password": os.environ["MATRIX_POSTGRES_PASSWORD"],
"database": os.environ.get("MATRIX_POSTGRES_DB", "synapse"),
"host": "matrix-db",
"cp_min": 5,
"cp_max": 10,
},
}
config["redis"] = {"enabled": True, "host": "matrix-redis"}
config["registration_shared_secret"] = os.environ["MATRIX_REGISTRATION_SHARED_SECRET"]
config["turn_uris"] = [
f"turn:{domain}:3478?transport=udp",
f"turn:{domain}:3478?transport=tcp",
]
config["turn_shared_secret"] = os.environ["MATRIX_TURN_SHARED_SECRET"]
config["turn_user_lifetime"] = "1h"
config["enable_registration"] = False
config["experimental_features"] = {
**(config.get("experimental_features") or {}),
"msc3266_enabled": True,
"msc4222_enabled": True,
}
config["login_via_existing_session"] = {
"enabled": True,
"require_ui_auth": False,
"token_timeout": "5m",
}
config["max_event_delay_duration"] = "24h"
config["rc_message"] = {"per_second": 0.5, "burst_count": 30}
config["rc_delayed_event_mgmt"] = {"per_second": 1, "burst_count": 20}
with open(path, "w", encoding="utf-8") as handle:
yaml.safe_dump(config, handle, sort_keys=False)
PY
exec /start.py
volumes:
- ./matrix/synapse:/data
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8008/_matrix/client/versions', timeout=2)\""]
interval: 10s
timeout: 5s
retries: 30
start_period: 20s
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-matrix.rule=Host(`${DOMAIN}`) && PathPrefix(`/_matrix`)
- traefik.http.routers.fedeo-matrix.entrypoints=websecure
- traefik.http.routers.fedeo-matrix.tls.certresolver=letsencrypt
- traefik.http.services.fedeo-matrix.loadbalancer.server.port=8008
- traefik.docker.network=fedeo_web
networks:
- web
- internal
matrix-well-known:
image: nginx:1.27-alpine
container_name: fedeo-matrix-well-known
restart: unless-stopped
command:
- /bin/sh
- -ec
- |
mkdir -p /usr/share/nginx/html/.well-known/matrix
cat >/usr/share/nginx/html/.well-known/matrix/client <<EOF
{
"m.homeserver": {
"base_url": "https://${DOMAIN}"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://${DOMAIN}/livekit/jwt"
}
]
}
EOF
cat >/usr/share/nginx/html/.well-known/matrix/server <<EOF
{
"m.server": "${MATRIX_SERVER_NAME:-${DOMAIN}}:443"
}
EOF
exec nginx -g 'daemon off;'
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolalloworiginlist=*
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowmethods=GET,OPTIONS
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowheaders=Content-Type,Authorization
- traefik.http.routers.fedeo-matrix-well-known.rule=Host(`${DOMAIN}`) && PathPrefix(`/.well-known/matrix`)
- traefik.http.routers.fedeo-matrix-well-known.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-well-known.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-well-known.middlewares=fedeo-matrix-well-known-cors
- traefik.http.services.fedeo-matrix-well-known.loadbalancer.server.port=80
- traefik.docker.network=fedeo_web
networks:
- web
matrix-turn:
image: instrumentisto/coturn:4
container_name: fedeo-matrix-turn
restart: unless-stopped
command:
- --fingerprint
- --use-auth-secret
- --static-auth-secret=${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret}
- --realm=${MATRIX_SERVER_NAME:-${DOMAIN}}
- --listening-port=3478
- --tls-listening-port=5349
- --min-port=49160
- --max-port=49200
- --no-cli
- --no-tlsv1
- --no-tlsv1_1
ports:
- "3478:3478/tcp"
- "3478:3478/udp"
- "5349:5349/tcp"
- "49160-49200:49160-49200/udp"
networks:
- internal
matrix-livekit:
image: livekit/livekit-server:v1.9
container_name: fedeo-matrix-livekit
restart: unless-stopped
depends_on:
- matrix-redis
entrypoint: /bin/sh
command:
- -ec
- |
cat >/tmp/livekit.yaml <<EOF
port: 7880
redis:
address: matrix-redis:6379
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
keys:
${LIVEKIT_KEY:-fedeo-livekit}: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
room:
auto_create: true
EOF
exec /livekit-server --config /tmp/livekit.yaml
ports:
- "7881:7881/tcp"
- "50000-50100:50000-50100/udp"
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-livekit-strip.stripprefix.prefixes=/livekit/sfu
- traefik.http.routers.fedeo-matrix-livekit.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/sfu`)
- traefik.http.routers.fedeo-matrix-livekit.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-livekit.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-livekit.middlewares=fedeo-matrix-livekit-strip
- traefik.http.services.fedeo-matrix-livekit.loadbalancer.server.port=7880
- traefik.docker.network=fedeo_web
networks:
- web
- internal
matrix-rtc-jwt:
image: ghcr.io/element-hq/lk-jwt-service:latest
container_name: fedeo-matrix-rtc-jwt
restart: unless-stopped
depends_on:
- matrix-livekit
- matrix-synapse
environment:
LIVEKIT_URL: wss://${DOMAIN}/livekit/sfu
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
LIVEKIT_FULL_ACCESS_HOMESERVERS: ${MATRIX_SERVER_NAME:-${DOMAIN}}
LIVEKIT_JWT_BIND: :8080
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-rtc-jwt-strip.stripprefix.prefixes=/livekit/jwt
- traefik.http.routers.fedeo-matrix-rtc-jwt.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/jwt`)
- traefik.http.routers.fedeo-matrix-rtc-jwt.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-rtc-jwt.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-rtc-jwt.middlewares=fedeo-matrix-rtc-jwt-strip
- traefik.http.services.fedeo-matrix-rtc-jwt.loadbalancer.server.port=8080
- traefik.docker.network=fedeo_web
networks:
- web
- internal
matrix-element:
image: vectorim/element-web:latest
container_name: fedeo-matrix-element
user: "0:0"
restart: unless-stopped
entrypoint: /bin/sh
command:
- -ec
- |
cat >/app/config.json <<EOF
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://${DOMAIN}",
"server_name": "${MATRIX_SERVER_NAME:-${DOMAIN}}"
}
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://${DOMAIN}/livekit/jwt"
}
],
"disable_custom_urls": false,
"disable_guests": true,
"brand": "FEDEO Matrix",
"default_theme": "light",
"features": {
"feature_video_rooms": true
}
}
EOF
exec nginx -g 'daemon off;'
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-matrix-element.rule=Host(`${DOMAIN}`) && PathPrefix(`/element`)
- traefik.http.routers.fedeo-matrix-element.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-element.tls.certresolver=letsencrypt
- traefik.http.middlewares.fedeo-matrix-element-strip.stripprefix.prefixes=/element
- traefik.http.routers.fedeo-matrix-element.middlewares=fedeo-matrix-element-strip
- traefik.http.services.fedeo-matrix-element.loadbalancer.server.port=80
- traefik.docker.network=fedeo_web
networks:
- web
networks:
web:
name: fedeo_web
driver: bridge
internal:
name: fedeo_internal
driver: bridge

View File

@@ -0,0 +1,29 @@
services:
website:
image: git.federspiel.tech/flfeders/fedeo/website:${FEDEO_WEBSITE_TAG:-latest}
restart: unless-stopped
environment:
- NODE_ENV=production
- NITRO_HOST=0.0.0.0
- NITRO_PORT=3000
- NUXT_HOST=0.0.0.0
- NUXT_PORT=3000
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.services.fedeo-website.loadbalancer.server.port=3000"
- "traefik.http.middlewares.fedeo-website-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.routers.fedeo-website.rule=Host(`${FEDEO_WEBSITE_HOST:-fedeo.de}`) || Host(`${FEDEO_WEBSITE_WWW_HOST:-www.fedeo.de}`)"
- "traefik.http.routers.fedeo-website.entrypoints=web"
- "traefik.http.routers.fedeo-website.middlewares=fedeo-website-redirect-web-secure"
- "traefik.http.routers.fedeo-website.service=fedeo-website"
- "traefik.http.routers.fedeo-website-secure.rule=Host(`${FEDEO_WEBSITE_HOST:-fedeo.de}`) || Host(`${FEDEO_WEBSITE_WWW_HOST:-www.fedeo.de}`)"
- "traefik.http.routers.fedeo-website-secure.entrypoints=web-secured"
- "traefik.http.routers.fedeo-website-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-website-secure.service=fedeo-website"
networks:
traefik:
external: true

View File

@@ -56,6 +56,22 @@ services:
- WEB_PUSH_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-}
- WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-}
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
- NODE_EXPORTER_URL=${NODE_EXPORTER_URL:-http://node-exporter:9100}
- TELEPHONY_ENABLED=${TELEPHONY_ENABLED:-false}
- TELEPHONY_ASTERISK_HTTP_URL=${TELEPHONY_ASTERISK_HTTP_URL:-}
- TELEPHONY_ASTERISK_WS_URL=${TELEPHONY_ASTERISK_WS_URL:-}
- TELEPHONY_SIP_DOMAIN=${TELEPHONY_SIP_DOMAIN:-localhost}
- TELEPHONY_ECHO_EXTENSION=${TELEPHONY_ECHO_EXTENSION:-600}
- TELEPHONY_EXTERNAL_PROVIDER=${TELEPHONY_EXTERNAL_PROVIDER:-}
- TELEPHONY_EXTERNAL_ENABLED=${TELEPHONY_EXTERNAL_ENABLED:-false}
- TELEPHONY_EXTERNAL_INBOUND_EXTENSION=${TELEPHONY_EXTERNAL_INBOUND_EXTENSION:-1001}
- TELEPHONY_ASTERISK_GENERATED_DIR=${TELEPHONY_ASTERISK_GENERATED_DIR:-/var/lib/fedeo/asterisk/generated}
- TELEPHONY_ASTERISK_AMI_HOST=${TELEPHONY_ASTERISK_AMI_HOST:-}
- TELEPHONY_ASTERISK_AMI_PORT=${TELEPHONY_ASTERISK_AMI_PORT:-5038}
- TELEPHONY_ASTERISK_AMI_USER=${TELEPHONY_ASTERISK_AMI_USER:-fedeo}
- TELEPHONY_ASTERISK_AMI_PASSWORD=${TELEPHONY_ASTERISK_AMI_PASSWORD:-fedeo-ami-dev}
volumes:
- ./telephony/generated:/var/lib/fedeo/asterisk/generated
networks:
- traefik
labels:
@@ -74,6 +90,23 @@ services:
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
node-exporter:
image: prom/node-exporter:v1.8.2
restart: unless-stopped
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro,rslave
networks:
- traefik
matrix-db:
image: postgres:16-alpine
restart: unless-stopped

View File

@@ -5,3 +5,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Einstieg
- [Bedienung](./bedienung/README.md)
- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md)

View File

@@ -0,0 +1,371 @@
# Kommunikationslösung auf Basis des Matrix-Standards
Dieser Entwurf beschreibt eine FEDEO-Kommunikationslösung für Chat, Anrufe und Videokonferenzen auf Basis des Matrix-Standards. Ziel ist eine souverän betreibbare Lösung, die Mandantenfähigkeit, Datenschutz, Rechteverwaltung und die bestehenden FEDEO-Workflows berücksichtigt.
## Zielbild
FEDEO erhält einen integrierten Kommunikationsbereich, der interne Zusammenarbeit und externe Kommunikation abdeckt:
- Chat in Einzel-, Gruppen-, Projekt-, Vorgangs- und Kundenräumen
- Audioanrufe aus Direktchats, Gruppenräumen und Kontakten
- Videokonferenzen mit Bildschirmfreigabe und Einladungslinks
- Ende-zu-Ende-verschlüsselte private Kommunikation
- revisionsfähige Verknüpfung von relevanten Kommunikationsereignissen mit FEDEO-Objekten
- optional föderierte Kommunikation mit externen Matrix-Organisationen
Matrix wird dabei nicht als isolierter Messenger betrieben, sondern als Kommunikationsschicht neben dem bestehenden FEDEO-Backend.
## Empfohlene Architektur
```text
Nutzerinnen und Nutzer
|
| FEDEO Web, Mobile App, optional Element Desktop/Mobile
v
FEDEO Frontend
|
| FEDEO API, SSO, Rechte, Objektkontext
v
FEDEO Backend
|
| Provisionierung, Webhooks, Audit-Metadaten
v
Matrix Homeserver
|
+-- PostgreSQL für Matrix-Daten
+-- Redis für Worker und Caches
+-- Medien-Repository für Anhänge
+-- TURN/STUN für direkte Medienverbindungen
+-- MatrixRTC / LiveKit SFU für Gruppenanrufe und Videokonferenzen
```
### Kernkomponenten
| Komponente | Empfehlung | Aufgabe |
| --- | --- | --- |
| Matrix Homeserver | Synapse | Standardnaher, bewährter Homeserver mit guter Betriebsdokumentation |
| Matrix Client im FEDEO Web | Matrix JS SDK oder eingebetteter Element-Web-Ausschnitt | Chat, Raumliste, Nachrichten, Reaktionen, Anhänge |
| Mobile Integration | Matrix SDK über FEDEO Mobile oder Deep Link zu Element X | Pushfähige mobile Kommunikation |
| Identität | OIDC/SSO über FEDEO Auth, perspektivisch Matrix Authentication Service | Einheitlicher Login und zentrale Nutzerverwaltung |
| Audio/Video | MatrixRTC mit Element Call und LiveKit SFU | Moderne Anrufe und Videokonferenzen |
| NAT Traversal | coturn | STUN/TURN für stabile Medienverbindungen |
| Reverse Proxy | bestehender Traefik-Ansatz | TLS, Routing, `.well-known/matrix/*` |
| Administration | FEDEO Admin-Oberfläche plus Synapse Admin API | Nutzer, Räume, Richtlinien, Sperren |
## Betriebsmodell
Für FEDEO ist ein eigener Matrix-Homeserver pro Installation oder pro großer Betreiberinstanz sinnvoll. Der Matrix-Server sollte nicht öffentlich als offener Registrierungsserver betrieben werden. Nutzer werden ausschließlich durch FEDEO angelegt, aktualisiert und deaktiviert.
Empfohlene Domains:
- `app.example.com`: FEDEO Oberfläche
- `matrix.example.com`: Matrix Client-Server und Federation API
- `call.example.com`: Element Call / MatrixRTC
- `livekit.example.com`: LiveKit SFU
- `turn.example.com`: TURN/STUN
Die öffentliche Matrix-Serverkennung kann trotzdem `example.com` lauten. Dafür werden `.well-known/matrix/client` und `.well-known/matrix/server` über Traefik ausgeliefert.
## Mandantenmodell
Matrix selbst ist raumbasiert, FEDEO ist mandantenbasiert. Deshalb sollte FEDEO die Mandantenlogik explizit auf Matrix-Räume und Spaces abbilden.
### Räume und Spaces
- Pro FEDEO-Mandant wird ein Matrix Space angelegt.
- Projekte, Vorgänge, Helpdesk-Konversationen, interne Teams und Kundenkontakte werden als Räume im Mandanten-Space geführt.
- Direkträume werden nutzerbezogen angelegt, aber über FEDEO mandantengebunden sichtbar gemacht.
- Externe Räume erhalten einen klaren Status, zum Beispiel `intern`, `extern`, `kunde`, `lieferant`.
### Raumalias-Konvention
Beispiele:
- `#tenant-<mandant>-team:example.com`
- `#tenant-<mandant>-project-<projekt>:example.com`
- `#tenant-<mandant>-ticket-<ticket>:example.com`
- `#tenant-<mandant>-customer-<kunde>:example.com`
Interne technische IDs sollten nicht als sichtbarer Anzeigename genutzt werden. Nutzerinnen und Nutzer sehen sprechende Namen wie `Projekt: Website Relaunch` oder `Kunde: Muster GmbH`.
## Rechte und Rollen
FEDEO bleibt führend für Berechtigungen. Matrix übernimmt die technische Durchsetzung im Raum.
| FEDEO-Rolle | Matrix-Abbildung |
| --- | --- |
| Mandantenadmin | Space-Admin und Raumadmin |
| Teamleitung | Moderatorin oder Moderator in Team- und Projekträumen |
| Mitarbeitende | Mitglied mit Schreibrechten |
| Externe Kontakte | Eingeschränkte Mitgliedschaft in ausgewählten Räumen |
| Automationen | Application-Service- oder Bot-Nutzer mit minimalen Rechten |
Änderungen an Rollen, Teams oder Mandantenzugehörigkeiten lösen im FEDEO-Backend eine Synchronisation mit Matrix aus. Beim Entzug eines Zugriffs wird die Person aus den betroffenen Räumen entfernt. Bei Ende-zu-Ende-verschlüsselten Räumen muss zusätzlich berücksichtigt werden, dass bereits erhaltene Nachrichten auf Geräten verbleiben können.
## Chat
Der Chat wird als erste Ausbaustufe umgesetzt.
### Funktionen
- Direktnachrichten
- Gruppenräume
- Mandanten-, Team-, Projekt- und Vorgangsräume
- Datei- und Bildanhänge
- Erwähnungen, Reaktionen und Lesestatus
- Suche in nicht verschlüsselten Räumen über den Homeserver
- lokale Suche in verschlüsselten Räumen über Client-Indizes
- Verknüpfung von Nachrichten mit FEDEO-Objekten
### Integration in FEDEO
FEDEO sollte keine vollständige Kopie aller Nachrichten in der eigenen Datenbank speichern. Stattdessen speichert FEDEO nur Referenzen:
- Matrix Raum-ID
- Matrix Event-ID
- FEDEO Objekt-Typ und Objekt-ID
- Zeitstempel
- beteiligter FEDEO-Nutzer
- optionale Vorschau, falls Datenschutzrichtlinie dies erlaubt
So bleibt Matrix das Kommunikationssystem, während FEDEO nachvollziehen kann, welche Kommunikation zu welchem Objekt gehört.
## Audioanrufe
Einzelanrufe können direkt über Matrix-VoIP in Direktchats gestartet werden. Der FEDEO-Client zeigt dafür in Kontakt-, Kunden-, Mitarbeitenden- und Chatansichten einen Anruf-Button.
### Anforderungen
- WebRTC-Unterstützung im Browser
- STUN/TURN über coturn
- Geräteauswahl für Mikrofon und Lautsprecher
- Anrufbenachrichtigung im Web und mobil
- Statusanzeige `verfügbar`, `beschäftigt`, `im Anruf`, `abwesend`
Für klassische Telefonie kann später ein SIP-Gateway ergänzt werden. Das sollte jedoch getrennt von der ersten Matrix-Einführung betrachtet werden, damit Chat und WebRTC-Kommunikation nicht durch Telefoniekomplexität ausgebremst werden.
## Videokonferenzen
Für Gruppenanrufe und Videokonferenzen wird MatrixRTC mit Element Call und LiveKit empfohlen. Matrix übernimmt dabei Raumzustand, Identität, Berechtigungen und Signalisierung; LiveKit übernimmt als SFU die effiziente Medienverteilung.
### Funktionen
- Videokonferenzen aus Matrix-Räumen
- spontane Besprechungen aus Projekten, Vorgängen oder Kundenakten
- Bildschirmfreigabe
- Einladungslink für externe Gäste
- Wartebereich für externe Gäste
- Moderationsrechte für Stummschalten, Entfernen und Raumverwaltung
- optionale Aufzeichnung erst in einer späteren, gesondert freizugebenden Ausbaustufe
### Konfiguration
Clients finden den MatrixRTC-Dienst über `.well-known/matrix/client`. Dort wird der LiveKit-JWT-Dienst als `org.matrix.msc4143.rtc_foci` angekündigt. Diese Datei muss öffentlich lesbar sein, als JSON ausgeliefert werden und CORS für Webclients erlauben.
Beispiel:
```json
{
"m.homeserver": {
"base_url": "https://matrix.example.com"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://call.example.com/livekit/jwt"
}
]
}
```
## Authentifizierung und Nutzerverwaltung
FEDEO sollte Identität und Lebenszyklus der Nutzer zentral steuern.
### Empfohlener Ablauf
1. Nutzer wird in FEDEO angelegt.
2. FEDEO erzeugt oder aktualisiert den Matrix-Nutzer.
3. FEDEO weist den Nutzer den passenden Spaces und Räumen zu.
4. Login erfolgt über FEDEO SSO/OIDC.
5. Deaktivierung in FEDEO deaktiviert auch den Matrix-Zugang und entfernt Raumzugriffe.
Die Matrix User-ID sollte stabil und nicht personenbezogen änderungsanfällig sein:
```text
@u_<fedeo_user_id>:example.com
```
Der Anzeigename kann weiterhin den echten Namen enthalten und bei Änderungen synchronisiert werden.
## Datenschutz und Compliance
Matrix erlaubt starke Datenschutzkonzepte, erfordert aber klare Betriebsregeln.
### Empfehlungen
- Ende-zu-Ende-Verschlüsselung für Direktnachrichten und vertrauliche Projekträume aktivieren.
- Nicht verschlüsselte Räume nur dort nutzen, wo serverseitige Suche, Archivierung oder Compliance-Funktionen ausdrücklich benötigt werden.
- Medienaufbewahrung mandantenweit konfigurierbar machen.
- Externe Gäste optisch klar kennzeichnen.
- Federation standardmäßig deaktivieren oder auf erlaubte Domains beschränken.
- Aufzeichnungen von Videokonferenzen nur mit expliziter Einwilligung und sichtbarem Status erlauben.
- Administrative Zugriffe protokollieren.
- Klare Löschfristen für Räume, Anhänge und Audit-Referenzen definieren.
## Federation
Matrix kann mit anderen Homeservern föderieren. Für FEDEO sollte Federation als kontrollierbare Option umgesetzt werden.
### Betriebsmodi
| Modus | Beschreibung | Empfehlung |
| --- | --- | --- |
| geschlossen | Keine Federation, nur interne Nutzer und explizite Gäste | Standard für kleine Installationen |
| allowlist | Federation nur mit freigegebenen Domains | Empfehlung für B2B-Kommunikation |
| offen | Federation mit beliebigen Matrix-Servern | Nur für bewusst öffentliche Communities |
Für steuer-, kunden- und projektnahe Kommunikation ist `allowlist` der beste Zielmodus.
## Brücken zu anderen Systemen
Matrix unterstützt Brücken zu anderen Kommunikationsdiensten. Für FEDEO sind Brücken nützlich, sollten aber nicht zur ersten Produktstufe gehören.
Mögliche spätere Erweiterungen:
- E-Mail-Brücke für Helpdesk- oder Kundenkommunikation
- Slack- oder Teams-Brücke für externe Projektpartner
- WhatsApp- oder SMS-Brücke nur nach gesonderter Datenschutzprüfung
- SIP-Brücke für Telefonie
Brücken müssen pro Mandant aktivierbar sein und brauchen klare Hinweise, welche Daten an externe Dienste fließen.
## FEDEO-Produktoberfläche
Die Kommunikation sollte in FEDEO an zwei Stellen sichtbar sein.
### Globaler Kommunikationsbereich
- Raumliste
- Direktnachrichten
- Suche
- Anrufe
- laufende Besprechungen
- Benachrichtigungen
### Objektbezogene Kommunikation
In Projekten, Kunden, Vorgängen, Helpdesk-Tickets und Dokumenten erscheint ein Kommunikations-Tab:
- zugeordneter Raum
- relevante Nachrichtenreferenzen
- Start von Chat, Anruf oder Besprechung
- Teilnehmerverwaltung entsprechend FEDEO-Rechten
So bleibt Kommunikation dort, wo die Arbeit stattfindet.
## Backend-Integration
Das FEDEO-Backend erhält ein Kommunikationsmodul mit folgenden Aufgaben:
- Matrix-Nutzer provisionieren
- Spaces und Räume anlegen
- Raum-Mitgliedschaften synchronisieren
- Matrix-Event-Webhooks empfangen
- FEDEO-Objekte mit Matrix-Räumen verknüpfen
- Benachrichtigungseinstellungen verwalten
- Admin-Aktionen auditieren
Technisch kann dies über Matrix Admin API, Client-Server API und Application Services erfolgen. Für Automationen empfiehlt sich ein eigener Application Service, weil er reservierte Nutzer- und Raum-Namensräume sauber verwalten kann.
## Deployment-Erweiterung
Der bestehende Docker-/Traefik-Ansatz kann um folgende Dienste erweitert werden:
- `matrix-synapse`
- `matrix-db` oder gemeinsame PostgreSQL-Instanz mit getrennter Datenbank
- `redis`
- `coturn`
- `element-web` optional als Fallback-Client
- `element-call`
- `livekit`
- `matrix-rtc-jwt-service`
Für produktive Installationen sollte Matrix eine eigene PostgreSQL-Datenbank erhalten. Medien sollten in S3-kompatiblen Speicher ausgelagert werden, damit große Anhänge und Konferenzartefakte nicht den Applikationsserver füllen.
## Monitoring
Wichtige Kennzahlen:
- aktive Nutzerinnen und Nutzer
- Anzahl Räume pro Mandant
- Nachrichtenrate
- Medien-Speicherverbrauch
- Zustellverzögerung
- fehlgeschlagene Anrufe
- LiveKit Paketverlust, Latenz und Teilnehmerzahl
- TURN-Nutzung
- Federation-Fehler
Logs von FEDEO, Synapse, LiveKit, coturn und Traefik sollten über eine gemeinsame Korrelation, zum Beispiel Request-ID oder Nutzer-ID, untersuchbar sein.
## Risiken und Gegenmaßnahmen
| Risiko | Gegenmaßnahme |
| --- | --- |
| Komplexität durch zwei Systeme | FEDEO bleibt führend für Nutzer, Rechte und Objektbezug |
| Datenschutz bei externen Räumen | Externe Kennzeichnung, Federation-Allowlist, Mandantenrichtlinien |
| E2EE erschwert Suche und Archivierung | Raumtyp bewusst wählen, lokale Suche, Metadatenreferenzen statt Vollkopie |
| Medienverbindungen scheitern in Firmennetzen | coturn sauber betreiben, UDP und TCP/TLS-Fallback anbieten |
| Betriebskosten durch Video | LiveKit skalierbar betreiben, Limits pro Mandant definieren |
| Gästezugriff wird unübersichtlich | Einladungslinks mit Ablaufdatum, Wartebereich, Moderationsrechte |
## Umsetzung in Phasen
### Phase 1: Fundament und Chat
- Synapse mit PostgreSQL, Redis, Traefik und `.well-known` betreiben
- FEDEO-Nutzer zu Matrix synchronisieren
- Mandanten-Spaces und erste Teamräume anlegen
- Chat im FEDEO-Frontend integrieren
- Benachrichtigungen und Raumreferenzen speichern
### Phase 2: Objektbezogene Kommunikation
- Räume automatisch für Projekte, Vorgänge und Kunden anlegen
- Kommunikations-Tab in FEDEO-Objekten ergänzen
- Rechteänderungen aus FEDEO nach Matrix synchronisieren
- externe Gäste einladen und kennzeichnen
### Phase 3: Audio und Video
- coturn bereitstellen
- MatrixRTC, Element Call und LiveKit integrieren
- Anruf- und Videobuttons in Chat, Kontakten und Projekten ergänzen
- Gäste-Links und Wartebereich umsetzen
### Phase 4: Compliance und Skalierung
- Aufbewahrungsrichtlinien pro Mandant
- Monitoring und Admin-Dashboards
- Federation-Allowlist
- optionale Brücken
- optionale Aufzeichnung mit Einwilligungsworkflow
## Offene Entscheidungen
- Soll Federation initial deaktiviert oder direkt mit Allowlist ausgeliefert werden?
- Welche Räume müssen serverseitig durchsuchbar sein und bleiben deshalb unverschlüsselt?
- Sollen externe Gäste Matrix-Konten erhalten oder nur temporäre Konferenzzugänge?
- Wird Element als sichtbarer Fallback-Client angeboten oder soll alles primär in FEDEO stattfinden?
- Welche Mandantenlimits gelten für Speicher, Teilnehmerzahl und Videodauer?
## Quellen und Standards
- Matrix Specification: https://spec.matrix.org/
- Matrix Application Services: https://matrix.org/docs/older/application-services/
- Matrix Bridges: https://www.matrix.org/docs/communities/bridging/
- Synapse Worker-Dokumentation: https://matrix-org.github.io/synapse/develop/workers.html
- Element Call Self-Hosting: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md
- Element MatrixRTC Konfiguration: https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/
- LiveKit Self-Hosting: https://docs.livekit.io/transport/self-hosting/

View File

@@ -0,0 +1,103 @@
# Telekom-Telefonie in FEDEO
FEDEO kann den lokalen Asterisk-Stack als Übergang zur externen Telefonie nutzen. Die Telekom-Anbindung wird mandantenbezogen in FEDEO unter **Firmeneinstellungen -> Integrationen -> Telefonie-Trunk** gepflegt.
## Zugangsdaten
Für Telekom-SIP ist der Registrar normalerweise `tel.t-online.de`. Die SIP-ID ist in der Regel die Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen, zum Beispiel `0301234567`.
Wenn dein Anschluss statt einer separaten SIP-ID die klassischen Zugangsdaten nutzt, kann der Authentifizierungsnutzer aus Anschlusskennung, Zugangsnummer, Mitbenutzernummer und `@t-online.de` gebildet werden:
```env
TELEPHONY_TELEKOM_AUTH_USER=<anschlusskennung><zugangsnummer>#<mitbenutzernummer>@t-online.de
```
## FEDEO Einstellungen
Öffne **Firmeneinstellungen -> Integrationen** und fülle den Bereich **Telefonie-Trunk** aus:
- `SIP-ID / Rufnummer`: Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen
- `Kennwort`: persönliches Kennwort oder SIP-Kennwort
- `Auth-User`: optional, falls dein Anschluss nicht direkt mit der SIP-ID authentifiziert
- `Absendernummer`: optional, meist identisch zur SIP-ID
- `Eingehende Nebenstelle`: lokale FEDEO/Asterisk-Nebenstelle, z. B. `1001`
- `Ausgehender Prefix`: standardmäßig `0`
- `Öffentliche Signaling-Adresse`: öffentliche IP oder DNS-Name, unter dem Asterisk von Telekom erreichbar ist
- `Öffentliche Medien-Adresse`: öffentliche RTP-Adresse, leer nutzt die Signaling-Adresse
- `Lokale Netze`: interne Netze, für die Asterisk keine öffentliche Adresse einsetzen soll
Das Kennwort wird nicht wieder an das Frontend zurückgegeben. In der Oberfläche siehst du nur, ob ein Kennwort gespeichert ist.
Nach dem Speichern muss die Konfiguration mit **In Asterisk anwenden** in die Asterisk-Include-Dateien geschrieben werden. FEDEO lädt danach PJSIP und den Dialplan über AMI neu und fordert die Telekom-Registration an. Den Laufzeitstatus findest du unter **Kommunikation -> Telefonie Setup** im Bereich **Externe Telefonie**.
Wenn du die öffentliche Signaling- oder Medien-Adresse änderst, muss Asterisk anschließend neu gestartet werden, weil PJSIP-Transporte diese Werte nicht vollständig im laufenden Betrieb neu laden.
## `.env` Fallback
Die `.env`-Werte bleiben nur als lokaler Fallback für den Asterisk-Teststack erhalten, falls keine mandantenbezogene Konfiguration gepflegt ist.
```env
TELEPHONY_ENABLED=true
TELEPHONY_EXTERNAL_PROVIDER=telekom
TELEPHONY_EXTERNAL_ENABLED=true
TELEPHONY_TELEKOM_ENABLED=true
TELEPHONY_TELEKOM_REGISTRAR=tel.t-online.de
TELEPHONY_TELEKOM_SIP_USER=<rufnummer_mit_vorwahl_ohne_sonderzeichen>
TELEPHONY_TELEKOM_AUTH_USER=
TELEPHONY_TELEKOM_PASSWORD=<persönliches_kennwort_oder_sip_kennwort>
TELEPHONY_TELEKOM_CALLER_ID=<rufnummer_mit_vorwahl_ohne_sonderzeichen>
TELEPHONY_TELEKOM_INBOUND_EXTENSION=1001
TELEPHONY_TELEKOM_OUTBOUND_PREFIX=0
TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated
TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev
TELEPHONY_ASTERISK_AMI_PORT=5038
TELEPHONY_ASTERISK_AMI_USER=fedeo
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
```
Wenn das Backend lokal auf dem Host läuft, muss es AMI über `127.0.0.1:5038` erreichen. Der Entwicklungsstack mappt dafür `TELEPHONY_DEV_AMI_PORT` auf den Asterisk-Container.
Wenn `TELEPHONY_TELEKOM_AUTH_USER` leer bleibt, verwendet Asterisk automatisch `TELEPHONY_TELEKOM_SIP_USER` als Authentifizierungsnutzer.
## Start
```bash
docker compose --profile telephony-dev up -d asterisk-dev
```
Beim Start erzeugt der Container die Dateien `pjsip.telekom.conf` und `extensions.telekom.conf` in einem Docker-Volume. Ausgehende Anrufe mit Prefix `0` und internationale Ziele mit `+` werden über den Telekom-Trunk geroutet. Eingehende Anrufe landen standardmäßig auf Nebenstelle `1001`.
## FreePBX als Diagnose-PBX
Für Provider-Tests kann zusätzlich das optionale Profil `freepbx-dev` gestartet werden. FreePBX ist hier nicht als dauerhafte FEDEO-Abhängigkeit gedacht, sondern als Referenzoberfläche, um Trunk-, NAT-, CLIP- und Routing-Parameter gegen einen Provider wie Easybell zu prüfen.
```bash
docker compose --profile freepbx-dev up -d freepbx-dev-db freepbx-dev
```
Beim ersten Start muss FreePBX einmal gegen die lokale MariaDB installiert werden:
```bash
docker compose --profile freepbx-dev exec -T -w /usr/local/src/freepbx freepbx-dev \
bash -lc 'php install -n --dbuser=freepbxuser --dbpass="$(cat /run/secrets/freepbx_user_password)" --dbhost=freepbx-dev-db'
```
Danach ist die Oberfläche lokal unter `http://localhost:18080` erreichbar. Beim ersten Öffnen zeigt FreePBX die Ersteinrichtung für den Web-Admin-Benutzer. Diese Zugangsdaten gelten nur für die FreePBX-Diagnoseoberfläche und sind unabhängig von FEDEO.
Die Standardports sind bewusst konfliktarm gesetzt:
- Web: `18080` / `18443`
- SIP UDP: `15060`
- RTP UDP: `18000-18100`
Das verwendete FreePBX-Image ist aktuell nur für `linux/amd64` veröffentlicht. Auf Apple-Silicon-Hosts nutzt Docker Desktop deshalb über `FREEPBX_DEV_PLATFORM=linux/amd64` Emulation; für Diagnosezwecke ist das ausreichend, aber nicht als Produktionssetup gedacht.
Für einen möglichst realistischen Easybell-Test kann der FEDEO-Asterisk kurz gestoppt und FreePBX auf dem üblichen SIP-Port gestartet werden:
```bash
docker compose --profile telephony-dev stop asterisk-dev
FREEPBX_DEV_SIP_PORT=5060 docker compose --profile freepbx-dev up -d freepbx-dev
```
In FreePBX sollte der RTP-Bereich unter **Settings -> Asterisk SIP Settings** ebenfalls auf `18000-18100` gesetzt werden, damit er zur Compose-Portfreigabe passt. Wenn der Trunk dort erfolgreich registriert und ein Testanruf möglich ist, können die funktionierenden PJSIP- und NAT-Werte nach FEDEO übernommen werden.

View File

@@ -0,0 +1,17 @@
# FEDEO Dev mit VPS-Asterisk
Der Easybell-Trunk liegt auf dem Testserver `188.245.76.1`. Lokal braucht FEDEO nur den WebSocket-Endpunkt und einen AMI-Tunnel.
## AMI-Tunnel starten
```sh
ssh -i /private/tmp/fedeo_testserver_key -N -L 5038:127.0.0.1:5038 root@188.245.76.1
```
## Backend mit VPS-Asterisk starten
```sh
./scripts/start-backend-vps-asterisk.sh
```
Die Variablen liegen in `telephony/vps-asterisk.env`. Dort werden keine Provider-Zugangsdaten gespeichert; der Trunk bleibt auf dem VPS.

View File

@@ -6,3 +6,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
- [Bedienung](./bedienung/README.md)
- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md)
- [Zentraler Push-Server für Selfhost-Instanzen](./zentraler-push-server.md)

View File

@@ -0,0 +1,59 @@
# Gmail-Anhänge herunterladen
Das Skript `scripts/download-gmail-attachments.py` lädt Anhänge aus einem Gmail-Postfach per IMAP herunter. Es nutzt nur Python-Standardbibliotheken.
## Voraussetzungen
- IMAP muss im Gmail-Konto aktiviert sein.
- Für Konten mit Zwei-Faktor-Authentifizierung wird ein Gmail App-Passwort benötigt.
- Python 3.9 oder neuer.
## Beispiele
Alle Anhänge aus dem kompletten Gmail-Postfach herunterladen:
```bash
GMAIL_APP_PASSWORD="dein-app-passwort" \
python3 scripts/download-gmail-attachments.py \
--email name@gmail.com \
--output gmail-anhaenge \
--group-by-message
```
Nur Anhänge ab einem bestimmten Datum herunterladen:
```bash
GMAIL_APP_PASSWORD="dein-app-passwort" \
python3 scripts/download-gmail-attachments.py \
--email name@gmail.com \
--since 2026-01-01 \
--output gmail-anhaenge
```
Nur ungelesene Mails durchsuchen:
```bash
GMAIL_APP_PASSWORD="dein-app-passwort" \
python3 scripts/download-gmail-attachments.py \
--email name@gmail.com \
--search UNSEEN
```
Verfügbare IMAP-Postfächer anzeigen, falls `[Gmail]/All Mail` nicht passt:
```bash
GMAIL_APP_PASSWORD="dein-app-passwort" \
python3 scripts/download-gmail-attachments.py \
--email name@gmail.com \
--list-mailboxes
```
Wenn `--password` nicht gesetzt ist und `GMAIL_APP_PASSWORD` fehlt, fragt das Skript das Passwort interaktiv ab.
## Häufige Optionen
- `--mailbox "[Gmail]/All Mail"` durchsucht standardmäßig alle Mails.
- `--list-mailboxes` zeigt alle verfügbaren Gmail-IMAP-Postfächer an.
- `--group-by-message` legt pro Mail einen Unterordner an.
- `--overwrite` überschreibt vorhandene Dateien.
- `--since YYYY-MM-DD` und `--before YYYY-MM-DD` grenzen den Zeitraum ein.

71
docs/telekom-telefonie.md Normal file
View File

@@ -0,0 +1,71 @@
# Telekom-Telefonie in FEDEO
FEDEO kann den lokalen Asterisk-Stack als Übergang zur externen Telefonie nutzen. Die Telekom-Anbindung wird mandantenbezogen in FEDEO unter **Firmeneinstellungen -> Integrationen -> Telefonie-Trunk** gepflegt.
## Zugangsdaten
Für Telekom-SIP ist der Registrar normalerweise `tel.t-online.de`. Die SIP-ID ist in der Regel die Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen, zum Beispiel `0301234567`.
Wenn dein Anschluss statt einer separaten SIP-ID die klassischen Zugangsdaten nutzt, kann der Authentifizierungsnutzer aus Anschlusskennung, Zugangsnummer, Mitbenutzernummer und `@t-online.de` gebildet werden:
```env
TELEPHONY_TELEKOM_AUTH_USER=<anschlusskennung><zugangsnummer>#<mitbenutzernummer>@t-online.de
```
## FEDEO Einstellungen
Öffne **Firmeneinstellungen -> Integrationen** und fülle den Bereich **Telefonie-Trunk** aus:
- `SIP-ID / Rufnummer`: Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen
- `Kennwort`: persönliches Kennwort oder SIP-Kennwort
- `Auth-User`: optional, falls dein Anschluss nicht direkt mit der SIP-ID authentifiziert
- `Absendernummer`: optional, meist identisch zur SIP-ID
- `Eingehende Nebenstelle`: lokale FEDEO/Asterisk-Nebenstelle, z. B. `1001`
- `Ausgehender Prefix`: standardmäßig `0`
- `Öffentliche Signaling-Adresse`: öffentliche IP oder DNS-Name, unter dem Asterisk von Telekom erreichbar ist
- `Öffentliche Medien-Adresse`: öffentliche RTP-Adresse, leer nutzt die Signaling-Adresse
- `Lokale Netze`: interne Netze, für die Asterisk keine öffentliche Adresse einsetzen soll
Das Kennwort wird nicht wieder an das Frontend zurückgegeben. In der Oberfläche siehst du nur, ob ein Kennwort gespeichert ist.
Nach dem Speichern muss die Konfiguration mit **In Asterisk anwenden** in die Asterisk-Include-Dateien geschrieben werden. FEDEO lädt danach PJSIP und den Dialplan über AMI neu und fordert die Telekom-Registration an. Den Laufzeitstatus findest du unter **Kommunikation -> Telefonie Setup** im Bereich **Externe Telefonie**.
Wenn du die öffentliche Signaling- oder Medien-Adresse änderst, muss Asterisk anschließend neu gestartet werden, weil PJSIP-Transporte diese Werte nicht vollständig im laufenden Betrieb neu laden.
## `.env` Fallback
Die `.env`-Werte bleiben nur als lokaler Fallback für den Asterisk-Teststack erhalten, falls keine mandantenbezogene Konfiguration gepflegt ist.
```env
TELEPHONY_ENABLED=true
TELEPHONY_EXTERNAL_PROVIDER=telekom
TELEPHONY_EXTERNAL_ENABLED=true
TELEPHONY_TELEKOM_ENABLED=true
TELEPHONY_TELEKOM_REGISTRAR=tel.t-online.de
TELEPHONY_TELEKOM_SIP_USER=<rufnummer_mit_vorwahl_ohne_sonderzeichen>
TELEPHONY_TELEKOM_AUTH_USER=
TELEPHONY_TELEKOM_PASSWORD=<persönliches_kennwort_oder_sip_kennwort>
TELEPHONY_TELEKOM_CALLER_ID=<rufnummer_mit_vorwahl_ohne_sonderzeichen>
TELEPHONY_TELEKOM_INBOUND_EXTENSION=1001
TELEPHONY_TELEKOM_OUTBOUND_PREFIX=0
TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated
TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev
TELEPHONY_ASTERISK_AMI_PORT=5038
TELEPHONY_ASTERISK_AMI_USER=fedeo
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
```
Wenn das Backend lokal auf dem Host läuft, muss es AMI über `127.0.0.1:5038` erreichen. Der Entwicklungsstack mappt dafür `TELEPHONY_DEV_AMI_PORT` auf den Asterisk-Container.
Wenn `TELEPHONY_TELEKOM_AUTH_USER` leer bleibt, verwendet Asterisk automatisch `TELEPHONY_TELEKOM_SIP_USER` als Authentifizierungsnutzer.
## Start
```bash
docker compose --profile telephony-dev up -d asterisk-dev
```
Beim Start erzeugt der Container die Dateien `pjsip.telekom.conf`, `extensions.telekom.conf` und `pjsip.webrtc.conf` in einem Docker-Volume. Ausgehende Anrufe mit Prefix `0` und internationale Ziele mit `+` werden über den Trunk geroutet. Eingehende Anrufe landen standardmäßig auf Nebenstelle `1001`; die konfigurierte Rufnummer wird zusätzlich als DID direkt geroutet.
Wenn Asterisk auf einem VPS oder hinter NAT läuft, sollten `TELEPHONY_ASTERISK_EXTERNAL_SIGNALING_ADDRESS` und `TELEPHONY_ASTERISK_EXTERNAL_MEDIA_ADDRESS` auf die öffentliche Asterisk-Adresse gesetzt werden. FEDEO schreibt daraus die WebRTC-Media-Adresse, damit Browser-Clients den RTP/ICE-Pfad sauber aushandeln.

17
docs/vps-asterisk-dev.md Normal file
View File

@@ -0,0 +1,17 @@
# FEDEO Dev mit VPS-Asterisk
Der Easybell-Trunk liegt auf dem Testserver `188.245.76.1`. Lokal braucht FEDEO nur den WebSocket-Endpunkt und einen AMI-Tunnel.
## AMI-Tunnel starten
```sh
ssh -i /private/tmp/fedeo_testserver_key -N -L 5038:127.0.0.1:5038 root@188.245.76.1
```
## Backend mit VPS-Asterisk starten
```sh
./scripts/start-backend-vps-asterisk.sh
```
Die Variablen liegen in `telephony/vps-asterisk.env`. Dort werden keine Provider-Zugangsdaten gespeichert; der Trunk bleibt auf dem VPS.

View File

@@ -0,0 +1,429 @@
# Zentraler Push-Server für FEDEO
Dieser Entwurf beschreibt einen zentral betriebenen FEDEO-Push-Server, über den selbst gehostete FEDEO-Instanzen Push Notifications zustellen können. Ziel ist, dass Selfhost-Betreiber keine eigenen Apple-, Google- oder Web-Push-Zugangsdaten verwalten müssen und FEDEO trotzdem mandantenfähig, datensparsam und zuverlässig benachrichtigen kann.
## Zielbild
FEDEO erhält einen zentralen Zustelldienst unter einer FEDEO-kontrollierten Domain, zum Beispiel `https://push.fedeo.cloud`. Jede selbst gehostete Instanz registriert sich dort einmalig als Push-Client. Danach sendet die Instanz nur noch neutrale Zustellaufträge an den zentralen Server. Der zentrale Server übernimmt:
- Verwaltung öffentlicher Push-Konfigurationen für Web, iOS und Android
- Annahme signierter Zustellaufträge von Selfhost-Instanzen
- Zustellung über Web Push, APNs und FCM
- Retry, Rate Limits, Fehlerklassifizierung und Deaktivierung ungültiger Geräte
- technische Zustellmetriken ohne fachliche Inhaltsdaten
Die fachliche Entscheidung, wer welche Benachrichtigung erhält, bleibt in der jeweiligen FEDEO-Instanz. Der zentrale Server ist nur Transport- und Zustellkomponente.
## Grundprinzipien
- **Datenminimierung:** Der zentrale Server speichert keine FEDEO-Nutzerdaten, keine Mandantendaten und möglichst keine lesbaren Nachrichtentexte.
- **Instanzhoheit:** Selfhost-Instanzen behalten Nutzerverwaltung, Benachrichtigungseinstellungen und Eventlogik.
- **Zentrale Provider-Schlüssel:** APNs-, FCM- und VAPID-Schlüssel liegen ausschließlich im zentralen Push-Server.
- **Signierte Aufträge:** Jede Instanz authentifiziert sich per rotierbarem Instanzschlüssel.
- **Mandantentrennung:** Instanzen und deren Geräte werden strikt getrennt, auch wenn sie denselben zentralen Dienst nutzen.
- **Ausfallsicherheit:** Der lokale FEDEO-Betrieb darf nicht blockieren, wenn der Push-Server temporär nicht erreichbar ist.
## Architektur
```text
FEDEO Web / Mobile App
|
| registriert Gerät bei lokaler Selfhost-Instanz
v
Selfhost-FEDEO-Instanz
|
| signierte API-Aufrufe
v
Zentraler FEDEO Push-Server
|
+-- PostgreSQL für Instanzen, Geräte, Zustellaufträge, Audit-Metadaten
+-- Redis oder Queue für Retry und Backpressure
+-- Worker für Web Push, APNs und FCM
+-- Provider-Schlüsselverwaltung
|
+--> Web Push Endpoints
+--> Apple Push Notification service
+--> Firebase Cloud Messaging
```
### Komponenten
| Komponente | Aufgabe |
| --- | --- |
| Selfhost-FEDEO-Backend | entscheidet Empfänger, erzeugt Notification Items, übermittelt Push-Aufträge |
| Zentrale Push API | registriert Instanzen und Geräte, nimmt Push-Aufträge an |
| Push Worker | sendet an Web Push, APNs und FCM, verarbeitet Provider-Fehler |
| Geräte-Registry | speichert technische Push Tokens oder Web-Push-Subscriptions |
| Zustelljournal | speichert technische Statuswerte, keine fachliche Historie |
| Admin-Konsole | zeigt Instanzstatus, Fehlerquoten, Rate Limits und Schlüsselrotation |
## Rollenverteilung
### Lokale FEDEO-Instanz
Die Selfhost-Instanz bleibt führend für:
- Nutzer und Mandanten
- Benachrichtigungseinstellungen
- Event-Typen und Kanäle
- fachliche Nachrichtentexte
- In-App-Benachrichtigungen
- lokale Audit- und Lesestatusdaten
Sie speichert weiterhin `notifications_items` und entscheidet, ob für ein Ereignis der Kanal `push` aktiv ist. Statt selbst über `web-push` zu senden, ruft sie den zentralen Push-Server auf.
### Zentraler Push-Server
Der zentrale Server ist zuständig für:
- öffentliche Push-Konfiguration für Clients
- Entgegennahme von Geräte-Registrierungen
- Mapping von lokalen Geräte-IDs auf Provider Tokens
- Zustellung an externe Push-Netze
- technische Fehlerbehandlung
- Missbrauchsschutz, Quotas und Sperren
Er kennt keine FEDEO-Berechtigungen. Wenn eine lokale Instanz einen gültig signierten Push-Auftrag sendet, wird dieser technisch zugestellt.
## Datenschutzmodell
Für die erste Ausbaustufe sollte der Push-Server Push-Inhalte nur transitär verarbeiten und nicht dauerhaft speichern. Dauerhaft gespeichert werden:
- Instanz-ID
- Geräte-ID
- Plattform: `web`, `ios`, `android`
- Provider-Endpunkt oder Provider-Token, verschlüsselt gespeichert
- Zeitpunkte: erstellt, zuletzt gesehen, deaktiviert
- Zustellstatus pro Auftrag: angenommen, gesendet, fehlgeschlagen
- technische Fehlercodes
Nicht dauerhaft gespeichert werden:
- Name, E-Mail oder lokale Nutzer-ID
- Mandantenname
- fachliche Objekt-IDs, sofern nicht zwingend notwendig
- vollständige Nachrichtentexte
Für Benachrichtigungen mit sensiblen Inhalten empfiehlt sich ein Payload-Modell mit generischem Text:
```json
{
"title": "Neue FEDEO-Benachrichtigung",
"body": "Öffne FEDEO, um die Details anzusehen.",
"data": {
"notificationId": "lokale-notification-id",
"instanceBaseUrl": "https://app.example.com"
}
}
```
Die lokale App lädt Details anschließend authentifiziert von der Selfhost-Instanz.
## Instanzregistrierung
Jede Selfhost-Instanz erhält beim Setup eine zentrale Instanzidentität.
### Ablauf
1. Betreiber öffnet in FEDEO den Bereich `Administration > Push Notifications`.
2. FEDEO erzeugt eine lokale Instanz-ID, falls noch keine existiert.
3. Betreiber meldet die Instanz am zentralen Push-Portal an oder nutzt einen geführten Aktivierungscode.
4. Der zentrale Server erzeugt einen Client-Schlüssel.
5. Die Selfhost-Instanz speichert `FEDEO_PUSH_INSTANCE_ID` und `FEDEO_PUSH_CLIENT_SECRET`.
6. Die Instanz ruft regelmäßig einen Healthcheck auf.
### Benötigte Umgebungsvariablen
```env
FEDEO_PUSH_MODE=central
FEDEO_PUSH_GATEWAY_URL=https://push.fedeo.cloud
FEDEO_PUSH_INSTANCE_ID=inst_...
FEDEO_PUSH_CLIENT_SECRET=...
FEDEO_PUSH_PAYLOAD_MODE=minimal
```
`FEDEO_PUSH_MODE=local` bleibt für Entwickler und Betreiber möglich, die eigene VAPID-Schlüssel nutzen möchten.
## Geräte-Registrierung
Die lokale FEDEO-Instanz sollte weiterhin der erste Ansprechpartner für den Client bleiben. Dadurch kann sie Authentifizierung und Mandantenzuordnung prüfen.
### Web Push
1. FEDEO Web fragt lokal `/api/notifications/push/config` ab.
2. Die lokale Instanz liefert bei `central` die öffentliche VAPID-Konfiguration des zentralen Servers.
3. Der Browser erzeugt eine Web-Push-Subscription.
4. FEDEO Web sendet die Subscription an die lokale Instanz.
5. Die lokale Instanz registriert die Subscription beim zentralen Push-Server.
6. Der zentrale Server gibt eine `centralDeviceId` zurück.
7. Die lokale Instanz speichert diese ID in `notification_push_subscriptions.meta`.
### Mobile Push
Für iOS und Android registriert die FEDEO-App das Gerät über die native Push-Schicht und sendet den Provider Token an die lokale Instanz. Die lokale Instanz leitet ihn an den zentralen Push-Server weiter. Der zentrale Server speichert den Token verschlüsselt und liefert eine `centralDeviceId` zurück.
## API-Entwurf
Alle Instanz-API-Aufrufe nutzen HTTPS und eine HMAC-Signatur oder kurze JWTs mit rotierbarem Secret.
### `GET /v1/public-config`
Liefert öffentliche Push-Parameter.
```json
{
"webPushPublicKey": "...",
"iosBundleId": "cloud.fedeo.app",
"androidSenderId": "..."
}
```
### `POST /v1/instances/heartbeat`
Meldet Status und Version der Selfhost-Instanz.
```json
{
"instanceId": "inst_123",
"fedeoVersion": "2026.05.22",
"baseUrl": "https://app.example.com",
"capabilities": ["web_push", "minimal_payload"]
}
```
### `POST /v1/devices`
Registriert oder aktualisiert ein Gerät.
```json
{
"localDeviceId": "sub_456",
"platform": "web",
"subscription": {
"endpoint": "https://...",
"keys": {
"p256dh": "...",
"auth": "..."
}
},
"meta": {
"browser": "Firefox",
"os": "macOS"
}
}
```
Antwort:
```json
{
"centralDeviceId": "dev_789",
"status": "active"
}
```
### `DELETE /v1/devices/{centralDeviceId}`
Deaktiviert ein Gerät.
### `POST /v1/push`
Nimmt einen Zustellauftrag entgegen.
```json
{
"idempotencyKey": "notification-item-id:dev_789",
"devices": ["dev_789"],
"priority": "normal",
"ttlSeconds": 3600,
"collapseKey": "communication.message.new",
"notification": {
"title": "Neue FEDEO-Benachrichtigung",
"body": "Öffne FEDEO, um die Details anzusehen."
},
"data": {
"notificationId": "lokale-notification-id",
"link": "/communication"
}
}
```
Antwort:
```json
{
"accepted": 1,
"rejected": 0,
"deliveryJobId": "job_abc"
}
```
### `GET /v1/push/{deliveryJobId}`
Liefert technische Zustellinformationen für Debugging und Support.
```json
{
"status": "completed",
"sent": 1,
"failed": 0,
"disabledDevices": []
}
```
## Lokale Backend-Anpassung
Der bestehende `NotificationService` kann um einen Provider ergänzt werden:
```text
NotificationService
|
+-- InAppDeliveryProvider
+-- EmailDeliveryProvider
+-- LocalWebPushDeliveryProvider
+-- CentralPushDeliveryProvider
```
`CentralPushDeliveryProvider` nutzt die lokalen Einträge aus `notification_push_subscriptions`, sucht aktive Geräte mit `centralDeviceId` und sendet Zustellaufträge an den zentralen Push-Server. Ohne `centralDeviceId` versucht er eine Nachregistrierung oder markiert den lokalen Eintrag als fehlerhaft.
Für Kompatibilität sollte die lokale Tabelle erweitert oder die vorhandene `meta`-Spalte genutzt werden:
```json
{
"centralDeviceId": "dev_789",
"platform": "web",
"registeredVia": "central",
"lastGatewaySyncAt": "2026-05-22T10:00:00Z"
}
```
## Zustellverhalten
### Idempotenz
Jede lokale Instanz sendet pro Notification Item und Gerät einen stabilen `idempotencyKey`. Der zentrale Server verarbeitet denselben Key innerhalb eines konfigurierbaren Fensters nur einmal.
### Retry
Der zentrale Server unterscheidet:
- temporäre Fehler: Netzwerk, Provider-Timeout, Rate Limit
- dauerhafte Fehler: ungültiges Token, abgelaufene Web-Push-Subscription
- Konfigurationsfehler: gesperrte Instanz, ungültige Signatur, Quota überschritten
Temporäre Fehler werden mit exponentiellem Backoff wiederholt. Dauerhafte Gerätefehler deaktivieren das zentrale Gerät und werden an die lokale Instanz zurückgemeldet.
### Rückmeldung an lokale Instanzen
Für die erste Ausbaustufe reicht Pull-basiertes Debugging über `GET /v1/push/{deliveryJobId}`. Später kann ein Webhook ergänzt werden:
```text
POST <selfhost-base-url>/api/notifications/push/gateway-callback
```
Damit kann die lokale Instanz ungültige Geräte automatisch deaktivieren.
## Sicherheit
### Authentifizierung
Empfohlen ist ein HMAC-Schema:
```text
X-Fedeo-Instance-Id: inst_123
X-Fedeo-Timestamp: 2026-05-22T10:00:00Z
X-Fedeo-Signature: hmac-sha256(...)
```
Signiert werden Methode, Pfad, Timestamp, Body-Hash und Instanz-ID. Requests mit zu altem Timestamp werden abgelehnt.
### Secret-Rotation
Der zentrale Server sollte pro Instanz zwei aktive Secrets unterstützen:
- `currentSecret`
- `nextSecret`
Die Selfhost-Instanz kann dadurch ohne Ausfall rotieren. Nach erfolgreichem Wechsel wird das alte Secret deaktiviert.
### Missbrauchsschutz
- Rate Limit pro Instanz
- Tageskontingent pro Instanz
- Maximalgröße für Payloads
- Blocklist für kompromittierte Instanzen
- Audit-Log für Admin-Aktionen
- strikte Validierung von Links, damit Pushes nicht zu fremden Domains leiten
## Betriebsmodell
### Deployment
Der zentrale Push-Server sollte als eigener Dienst betrieben werden:
- `push-api`: HTTP API für Instanzen und Admin-Konsole
- `push-worker`: Queue-Verarbeitung und Provider-Zustellung
- `postgres`: Instanzen, Geräte, Jobs, Audit
- `redis`: Queue, Rate Limits, kurzlebige Idempotenzdaten
- `traefik` oder anderer Reverse Proxy mit TLS
### Monitoring
Wichtige Metriken:
- angenommene Push-Aufträge pro Instanz
- Zustellquote pro Plattform
- Provider-Latenz
- Fehlerrate nach Fehlerklasse
- deaktivierte Geräte
- Queue-Länge und Retry-Alter
- Signaturfehler und Rate-Limit-Treffer
### Verfügbarkeit
Wenn der zentrale Push-Server nicht erreichbar ist, markiert die lokale Instanz Push-Zustellungen als temporär fehlgeschlagen und versucht sie später erneut. In-App-Benachrichtigungen bleiben davon unberührt.
## Migrationspfad
### Phase 1: Web Push Gateway
- zentrale VAPID-Schlüssel bereitstellen
- Selfhost-Instanzen gegen Push-Server authentifizieren
- Web-Push-Subscriptions zentral registrieren
- bestehendes lokales Web-Push-Sending optional durch Gateway ersetzen
- minimale Payloads verwenden
### Phase 2: Mobile Push
- iOS und Android Tokens registrieren
- APNs und FCM im zentralen Worker anbinden
- Deep Links zur passenden Selfhost-Instanz öffnen
- Token-Rotation und App-Neuinstallation robust behandeln
### Phase 3: Rückmeldungen und Admin
- Zustellcallbacks an Selfhost-Instanzen
- Admin-Konsole für Instanzen, Sperren, Quotas und Fehleranalyse
- Secret-Rotation per Oberfläche
- Export technischer Logs für Supportfälle
### Phase 4: Erweiterte Kanäle
- mandantenweite Push-Richtlinien
- gebündelte Pushes
- stille Pushes für Synchronisation
- optional Webhook-Zustellung für Integrationen
## Offene Entscheidungen
- Sollen Push-Inhalte standardmäßig immer minimal sein oder darf eine Instanz explizit lesbare Titel und Texte erlauben?
- Wird die Instanzregistrierung über ein zentrales FEDEO-Konto oder über offline erzeugte Aktivierungscodes erfolgen?
- Soll der zentrale Push-Server Bestandteil eines größeren FEDEO-Cloud-Portals werden?
- Wie lange sollen technische Zustelllogs maximal aufbewahrt werden?
- Welche Quotas gelten für kostenlose, lizenzierte und interne Instanzen?
## Empfehlung für den ersten Schnitt
Für den ersten produktionsnahen Stand sollte FEDEO nur Web Push über den zentralen Server abwickeln. Das passt direkt zum vorhandenen Benachrichtigungssystem und reduziert den Betreiberaufwand für Selfhost-Instanzen deutlich. Die lokale Instanz bleibt fachlich führend, der zentrale Server sieht nur technische Geräte und minimale Zustellaufträge.
Die wichtigste Architekturentscheidung ist dabei, den zentralen Server nicht als Benachrichtigungslogik zu bauen, sondern als schlanken Push-Transport. Dadurch bleibt Selfhosting souverän, während FEDEO trotzdem die komplizierten Provider-Schlüssel und mobilen Push-Integrationen zentral beherrschen kann.

View File

@@ -1,8 +1,7 @@
<script setup>
const toast = useToast()
const dataStore = useDataStore()
const modal = useModal()
const props = defineProps({
documentData: {
type: Object,
@@ -15,353 +14,264 @@ const props = defineProps({
returnEmit: {
type: Boolean
},
})
const emit = defineEmits(["updateNeeded"])
const folders = ref([])
const filetypes = ref([])
const documentboxes = ref([])
const getFiletypeId = () => props.documentData.type && typeof props.documentData.type === "object"
? props.documentData.type.id
: props.documentData.type || null
const selectedFiletype = ref(getFiletypeId())
const resourceOptions = ref([
{label: "Projekt", value: "project", entity: "projects", optionAttr: "name", route: (item) => `/standardEntity/projects/show/${item.id}`},
{label: "Kunde", value: "customer", entity: "customers", optionAttr: "name", route: (item) => `/standardEntity/customers/show/${item.id}`},
{label: "Lieferant", value: "vendor", entity: "vendors", optionAttr: "name", route: (item) => `/standardEntity/vendors/show/${item.id}`},
{label: "Fahrzeug", value: "vehicle", entity: "vehicles", optionAttr: "licensePlate", route: (item) => `/standardEntity/vehicles/show/${item.id}`},
{label: "Objekt", value: "plant", entity: "plants", optionAttr: "name", route: (item) => `/standardEntity/plants/show/${item.id}`},
{label: "Vertrag", value: "contract", entity: "contracts", optionAttr: "name", route: (item) => `/standardEntity/contracts/show/${item.id}`},
{label: "Produkt", value: "product", entity: "products", optionAttr: "name", route: (item) => `/standardEntity/products/show/${item.id}`},
{label: "Ausgangsbeleg", value: "createddocument", entity: "createddocuments", optionAttr: "documentNumber", route: (item) => `/createDocument/show/${item.id}`},
{label: "Eingangsrechnung", value: "incominginvoice", entity: "incominginvoices", optionAttr: "reference", route: (item) => `/incomingInvoices/show/${item.id}`},
{label: "Inventarartikel", value: "inventoryitem", entity: "inventoryitems", optionAttr: "name", route: (item) => `/standardEntity/inventoryitems/show/${item.id}`},
{label: "Überprüfung", value: "check", entity: "checks", optionAttr: "name", route: (item) => `/standardEntity/checks/show/${item.id}`},
{label: "Mitarbeiter", value: "authProfile", entity: "profiles", optionAttr: "fullName", route: (item) => `/staff/profiles/${item.id}`}
])
const resourceToAssign = ref("project")
const itemOptions = ref([])
const idToAssign = ref(null)
const selectedResource = computed(() => resourceOptions.value.find((option) => option.value === resourceToAssign.value))
const setup = async () => {
const data = await useEntities("folders").select()
data.forEach(folder => {
let name = folder.name
const addParent = (item) => {
name = `${item.name} > ${name}`
if(item.parent){
addParent(data.find(i => i.id === item.parent))
} else {
folders.value.push({
id: folder.id,
name: name,
})
}
}
if(folder.parent) {
addParent(data.find(i => i.id === folder.parent))
} else {
folders.value.push({
id: folder.id,
name: folder.name,
})
}
})
filetypes.value = await useEntities("filetags").select()
//documentboxes.value = await useEntities("documentboxes").select()
selectedFiletype.value = getFiletypeId()
await getItemsBySelectedResource()
}
setup()
const updateDocument = async () => {
const {url, ...objData} = props.documentData
delete objData.url
delete objData.filetags
/*console.log(objData)
if(objData.project) objData.project = objData.project.id
if(objData.customer) objData.customer = objData.customer.id
if(objData.contract) objData.contract = objData.contract.id
if(objData.vendor) objData.vendor = objData.vendor.id
if(objData.plant) objData.plant = objData.plant.id
if(objData.createddocument) objData.createddocument = objData.createddocument.id
if(objData.vehicle) objData.vehicle = objData.vehicle.id
if(objData.product) objData.product = objData.product.id
if(objData.profile) objData.profile = objData.profile.id
if(objData.check) objData.check = objData.check.id
if(objData.inventoryitem) objData.inventoryitem = objData.inventoryitem.id
if(objData.incominginvoice) objData.incominginvoice = objData.incominginvoice.id*/
console.log(objData)
const {data,error} = await useEntities("files").update(objData.id, objData)
if(error) {
console.log(error)
} else {
console.log(data)
const updateDocument = async (payload, closeAfterUpdate = false) => {
try {
await useEntities("files").update(props.documentData.id, payload, true)
Object.assign(props.documentData, payload)
toast.add({title: "Datei aktualisiert"})
modal.close()
emit("updateNeeded")
//openShowModal.value = false
if (closeAfterUpdate) modal.close()
} catch (error) {
console.error(error)
toast.add({title: "Datei konnte nicht aktualisiert werden", color: "error"})
}
}
const archiveDocument = async () => {
props.documentData.archived = true
await updateDocument()
modal.close()
emit("update")
await updateDocument({archived: true}, true)
}
const resourceOptions = ref([
{label: 'Projekt', value: 'project', optionAttr: "name"},
{label: 'Kunde', value: 'customer', optionAttr: "name"},
{label: 'Lieferant', value: 'vendor', optionAttr: "name"},
{label: 'Fahrzeug', value: 'vehicle', optionAttr: "licensePlate"},
{label: 'Objekt', value: 'plant', optionAttr: "name"},
{label: 'Vertrag', value: 'contract', optionAttr: "name"},
{label: 'Produkt', value: 'product', optionAttr: "name"}
])
const resourceToAssign = ref("project")
const itemOptions = ref([])
const idToAssign = ref(null)
const getItemsBySelectedResource = async () => {
if(resourceToAssign.value === "project") {
itemOptions.value = await useEntities("projects").select()
} else if(resourceToAssign.value === "customer") {
itemOptions.value = await useEntities("customers").select()
} else if(resourceToAssign.value === "vendor") {
itemOptions.value = await useEntities("vendors").select()
} else if(resourceToAssign.value === "vehicle") {
itemOptions.value = await useEntities("vehicles").select()
} else if(resourceToAssign.value === "product") {
itemOptions.value = await useEntities("products").select()
} else if(resourceToAssign.value === "plant") {
itemOptions.value = await useEntities("plants").select()
} else if(resourceToAssign.value === "contract") {
itemOptions.value = await useEntities("contracts").select()
} else {
itemOptions.value = []
}
idToAssign.value = null
itemOptions.value = selectedResource.value?.entity
? await useEntities(selectedResource.value.entity).select()
: []
}
getItemsBySelectedResource()
const updateDocumentAssignment = async () => {
props.documentData[resourceToAssign.value] = idToAssign.value
await updateDocument()
if (!selectedResource.value || !idToAssign.value) return
await updateDocument({[selectedResource.value.value]: idToAssign.value})
props.documentData[selectedResource.value.value] = itemOptions.value.find((item) => item.id === idToAssign.value) || idToAssign.value
idToAssign.value = null
}
const folderToMoveTo = ref(null)
const moveFile = async () => {
const res = await useEntities("files").update(props.documentData.id, {folder: folderToMoveTo.value})
modal.close()
const removeAssignment = async (assignment) => {
await updateDocument({[assignment.value]: null})
props.documentData[assignment.value] = null
}
const getAssignmentItem = (assignment) => {
const value = props.documentData[assignment.value]
return value && typeof value === "object" ? value : null
}
const getAssignmentLabel = (assignment) => {
const value = props.documentData[assignment.value]
if (!value) return ""
if (typeof value === "object") return value[assignment.optionAttr] || value.name || value.id
return value
}
const currentAssignments = computed(() =>
resourceOptions.value
.filter((assignment) => Boolean(props.documentData[assignment.value]))
.map((assignment) => ({
...assignment,
item: getAssignmentItem(assignment),
display: getAssignmentLabel(assignment)
}))
)
const displayedFileTags = computed(() => {
if (Array.isArray(props.documentData.filetags) && props.documentData.filetags.length) {
return props.documentData.filetags
}
if (props.documentData.type && typeof props.documentData.type === "object") {
return [props.documentData.type]
}
const selected = filetypes.value.find((filetype) => filetype.id === props.documentData.type)
return selected ? [selected] : []
})
const updateFiletype = async () => {
await updateDocument({type: selectedFiletype.value || null})
props.documentData.type = selectedFiletype.value || null
}
setup()
</script>
<template>
<UModal fullscreen >
<UModal fullscreen>
<template #content>
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }" class="h-full">
<template #header>
<div class="flex flex-row justify-between">
<div class="flex items-center gap-2">
<UBadge
v-for="tag in props.documentData.filetags"
>
{{tag.name}}
</UBadge>
<template #header>
<div class="flex flex-row justify-between">
<div class="flex items-center gap-2">
<UBadge
v-for="tag in displayedFileTags"
:key="tag.id"
>
{{tag.name}}
</UBadge>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="modal.close()" />
</div>
</template>
<div class="flex flex-row">
<div class="w-1/3">
<PDFViewer
v-if="props.documentData.id && props.documentData.path.toLowerCase().includes('pdf')"
:file-id="props.documentData.id" />
<img
v-else
class="w-full"
:src="props.documentData.url"
alt=""
/>
</div>
<div class="w-2/3 p-5">
<UButtonGroup>
<ArchiveButton
color="error"
variant="outline"
type="files"
@confirmed="archiveDocument"
/>
<UButton
:to="props.documentData.url"
variant="outline"
icon="i-heroicons-arrow-top-right-on-square"
target="_blank"
>
Öffnen
</UButton>
</UButtonGroup>
<USeparator class="my-3" label="Zuweisungen"/>
<div class="space-y-2">
<div
v-for="assignment in currentAssignments"
:key="assignment.value"
class="flex items-center justify-between gap-3 rounded-md border border-gray-200 p-2 dark:border-gray-800"
>
<div class="min-w-0">
<div class="text-xs text-gray-500">{{ assignment.label }}</div>
<nuxt-link
v-if="assignment.item"
:to="assignment.route(assignment.item)"
class="block truncate font-medium text-primary"
>
{{ assignment.display }}
</nuxt-link>
<span v-else class="font-medium">{{ assignment.display }}</span>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
@click="removeAssignment(assignment)"
/>
</div>
<UAlert
v-if="currentAssignments.length === 0"
icon="i-heroicons-link"
title="Noch keine Zuweisungen vorhanden"
color="neutral"
variant="soft"
/>
</div>
<USeparator class="my-3" label="Datei zuweisen"/>
<UFormField label="Bereich auswählen">
<USelectMenu
v-model="resourceToAssign"
:items="resourceOptions"
value-key="value"
label-key="label"
@update:model-value="getItemsBySelectedResource"
/>
</UFormField>
<UFormField class="mt-3" label="Eintrag auswählen">
<USelectMenu
v-model="idToAssign"
:items="itemOptions"
:label-key="selectedResource ? selectedResource.optionAttr : 'name'"
value-key="id"
:search-input="{ placeholder: 'Eintrag suchen...' }"
:filter-fields="[selectedResource ? selectedResource.optionAttr : 'name']"
/>
</UFormField>
<div class="mt-2 flex justify-end">
<UButton
icon="i-heroicons-link"
:disabled="!idToAssign"
@click="updateDocumentAssignment"
>
Zuweisen
</UButton>
</div>
<USeparator class="my-5" label="Dateityp"/>
<InputGroup class="w-full">
<USelectMenu
v-model="selectedFiletype"
class="flex-auto"
value-key="id"
label-key="name"
:items="filetypes"
@update:model-value="updateFiletype"
/>
</InputGroup>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="modal.close()" />
</div>
</template>
<div class="flex flex-row">
<div :class="false ? ['w-full'] : ['w-1/3']">
<PDFViewer
v-if="props.documentData.id && props.documentData.path.toLowerCase().includes('pdf')"
:file-id="props.documentData.id" />
<img
class=" w-full"
:src="props.documentData.url"
alt=""
v-else
/>
</div>
<div class="w-2/3 p-5" v-if="!false">
<UButtonGroup>
<ArchiveButton
color="error"
variant="outline"
type="files"
@confirmed="archiveDocument"
/>
<UButton
:to="props.documentData.url"
variant="outline"
icon="i-heroicons-arrow-top-right-on-square"
target="_blank"
>
Öffnen
</UButton>
</UButtonGroup>
<USeparator label="Zuweisungen"/>
<table class="w-full">
<tr v-if="props.documentData.project">
<td>Projekt</td>
<td>
<nuxt-link :to="`/standardEntity/projects/show/${props.documentData.project.id}`">{{props.documentData.project.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.customer">
<td>Kunde</td>
<td>
<nuxt-link :to="`/standardEntity/customers/show/${props.documentData.customer.id}`">{{props.documentData.customer.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.vendor">
<td>Lieferant</td>
<td>
<nuxt-link :to="`/standardEntity/vendors/show/${props.documentData.vendor.id}`">{{props.documentData.vendor.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.createddocument">
<td>Ausgangsbeleg</td>
<td>
<nuxt-link :to="`/createDocument/show/${props.documentData.createddocument.id}`">{{props.documentData.createddocument.documentNumber}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.plant">
<td>Objekt</td>
<td>
<nuxt-link :to="`/standardEntity/plants/show/${props.documentData.plant.id}`">{{props.documentData.plant.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.contract">
<td>Vertrag</td>
<td>
<nuxt-link :to="`/standardEntity/contracts/show/${props.documentData.contract.id}`">{{props.documentData.contract.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.vehicle">
<td>Fahrzeug</td>
<td>
<nuxt-link :to="`/standardEntity/vehicles/show/${props.documentData.vehicle.id}`">{{props.documentData.vehicle.licensePlate}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.product">
<td>Artikel</td>
<td>
<nuxt-link :to="`/standardEntity/products/show/${props.documentData.product.id}`">{{props.documentData.product.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.inventoryitem">
<td>Inventarartikel</td>
<td>
<nuxt-link :to="`/standardEntity/inventoryitem/show/${props.documentData.inventoryitem.id}`">{{props.documentData.inventoryitem.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.check">
<td>Überprüfung</td>
<td>
<nuxt-link :to="`/standardEntity/checks/show/${props.documentData.check.id}`">{{props.documentData.check.name}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.profile">
<td>Mitarbeiter</td>
<td>
<nuxt-link :to="`/profiles/show/${props.documentData.profile.id}`">{{props.documentData.profile.fullName}}</nuxt-link>
</td>
</tr>
<tr v-if="props.documentData.incominginvoice">
<td>Eingangsrechnung</td>
<td>
<nuxt-link :to="`/incomingInvoices/show/${props.documentData.incominginvoice.id}`">{{props.documentData.incominginvoice.reference}}</nuxt-link>
</td>
</tr>
</table>
<USeparator class="my-3" label="Datei zuweisen"/>
<UFormField
label="Resource auswählen"
>
<USelectMenu
:items="resourceOptions"
v-model="resourceToAssign"
value-key="value"
label-key="label"
@change="getItemsBySelectedResource"
>
</USelectMenu>
</UFormField>
<UFormField
label="Eintrag auswählen:"
>
<USelectMenu
:items="itemOptions"
v-model="idToAssign"
:label-key="resourceOptions.find(i => i.value === resourceToAssign)? resourceOptions.find(i => i.value === resourceToAssign).optionAttr : 'name'"
value-key="id"
@change="updateDocumentAssignment"
></USelectMenu>
</UFormField>
<USeparator class="my-5" label="Datei verschieben"/>
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="folderToMoveTo"
value-key="id"
label-key="name"
:items="folders"
/>
<UButton
@click="moveFile"
variant="outline"
:disabled="!folderToMoveTo"
>Verschieben</UButton>
</InputGroup>
<USeparator class="my-5" label="Dateityp"/>
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="props.documentData.type"
value-key="id"
label-key="name"
:items="filetypes"
@change="updateDocument"
/>
</InputGroup>
<USeparator class="my-5" label="Dokumentenbox" />
<InputGroup class="w-full">
<USelectMenu
class="flex-auto"
v-model="props.documentData.documentbox"
value-key="id"
label-key="key"
:items="documentboxes"
@change="updateDocument"
/>
</InputGroup>
</div>
</div>
</UCard>
</template>
</UModal>
</template>
<style scoped>
.bigPreview {
width: 100%;
aspect-ratio: 1/ 1.414;
}
</style>

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