Compare commits

..

135 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
214 changed files with 36408 additions and 871 deletions

View File

@@ -37,6 +37,16 @@ S3_ACCESS_KEY=fedeo-minio
S3_SECRET_KEY=change-this-minio-password S3_SECRET_KEY=change-this-minio-password
S3_BUCKET=fedeo 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 M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend API_BASE_URL=https://app.example.com/backend
GOCARDLESS_BASE_URL=https://api.gocardless.com GOCARDLESS_BASE_URL=https://api.gocardless.com
@@ -53,6 +63,52 @@ OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license 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, # Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt. # Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
@@ -61,6 +117,7 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
FEDEO_BOOTSTRAP_MATRIX=true
# FEDEO Matrix-Kommunikation # FEDEO Matrix-Kommunikation
# #

View File

@@ -89,23 +89,25 @@ Wenn du MinIO verwendest, setze zusatzlich:
## Deploy-Struktur ## 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 ```bash
git clone <DEIN-REPO-URL> /opt/fedeo mkdir -p /opt/fedeo/scripts
cd /opt/fedeo 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: Die Verzeichnisstruktur sollte dann mindestens so aussehen:
```text ```text
/opt/fedeo/ /opt/fedeo/
docker-compose.selfhost.yml docker-compose.yml
.env .env
backend/ scripts/
frontend/
traefik/ traefik/
letsencrypt/ letsencrypt/
logs/ logs/
@@ -130,7 +132,7 @@ Als Startpunkt kannst du die Beispielumgebung kopieren:
cp .env.example .env 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: Alternativ kannst du die Konfiguration geführt erzeugen lassen:
@@ -138,7 +140,7 @@ Alternativ kannst du die Konfiguration geführt erzeugen lassen:
bash scripts/selfhost-setup.sh bash scripts/selfhost-setup.sh
``` ```
Auf einem frischen Server kannst du den Checkout und die Konfiguration direkt per One-Liner vorbereiten: Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration direkt per One-Liner vorbereiten:
```bash ```bash
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
@@ -150,7 +152,7 @@ Der schnelle One-Liner mit direktem Stack-Start:
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start 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, klont oder aktualisiert FEDEO nach `/opt/fedeo` und startet anschließend den geführten Setup-Assistenten. 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: Für den schnellen Standardpfad:
@@ -202,6 +204,12 @@ S3_ACCESS_KEY=fedeo-minio
S3_SECRET_KEY=change-this-minio-password S3_SECRET_KEY=change-this-minio-password
S3_BUCKET=fedeo 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 M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend API_BASE_URL=https://app.example.com/backend
GOCARDLESS_BASE_URL=https://api.gocardless.com GOCARDLESS_BASE_URL=https://api.gocardless.com
@@ -246,7 +254,9 @@ Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BO
## Docker Compose mit optionalem S3 und Matrix ## 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`. 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`.
@@ -336,8 +346,7 @@ services:
- internal - internal
backend: backend:
build: image: git.federspiel.tech/flfeders/fedeo/backend:dev
context: ./backend
container_name: fedeo-backend container_name: fedeo-backend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -392,8 +401,7 @@ services:
- internal - internal
frontend: frontend:
build: image: git.federspiel.tech/flfeders/fedeo/frontend:dev
context: ./frontend
container_name: fedeo-frontend container_name: fedeo-frontend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -446,8 +454,7 @@ Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit M
Im Deploy-Verzeichnis: Im Deploy-Verzeichnis:
```bash ```bash
docker compose -f docker-compose.selfhost.yml build docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
docker compose -f docker-compose.selfhost.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. 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.
@@ -455,15 +462,15 @@ Synapse erzeugt `matrix/synapse/homeserver.yaml` beim ersten Start automatisch u
Danach Status prufen: Danach Status prufen:
```bash ```bash
docker compose -f docker-compose.selfhost.yml ps docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
docker compose -f docker-compose.selfhost.yml logs -f traefik docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.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 logs -f backend
``` ```
Wenn du Migrationen manuell ausführen möchtest: Wenn du Migrationen manuell ausführen möchtest:
```bash ```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 ## Funktionsprufung
@@ -487,14 +494,16 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
Bei neuen Versionen: Bei neuen Versionen:
```bash ```bash
git pull curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
docker compose -f docker-compose.selfhost.yml build curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
docker compose -f docker-compose.selfhost.yml up -d 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. 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 ## Backup-Empfehlung

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

@@ -309,6 +309,55 @@
"when": 1780156800000, "when": 1780156800000,
"tag": "0043_communication_rooms", "tag": "0043_communication_rooms",
"breakpoints": true "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

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

@@ -27,6 +27,7 @@ export * from "./customerspaces"
export * from "./customerinventoryitems" export * from "./customerinventoryitems"
export * from "./devices" export * from "./devices"
export * from "./documentboxes" export * from "./documentboxes"
export * from "./emails"
export * from "./enums" export * from "./enums"
export * from "./events" export * from "./events"
export * from "./entitybankaccounts" export * from "./entitybankaccounts"
@@ -47,6 +48,8 @@ export * from "./historyitems"
export * from "./holidays" export * from "./holidays"
export * from "./hourrates" export * from "./hourrates"
export * from "./incominginvoices" export * from "./incominginvoices"
export * from "./instance_agents"
export * from "./instance_agent_scan_jobs"
export * from "./inventoryitemgroups" export * from "./inventoryitemgroups"
export * from "./inventoryitems" export * from "./inventoryitems"
export * from "./letterheads" export * from "./letterheads"
@@ -55,6 +58,7 @@ export * from "./movements"
export * from "./m2m_api_keys" export * from "./m2m_api_keys"
export * from "./notifications_event_types" export * from "./notifications_event_types"
export * from "./notifications_items" export * from "./notifications_items"
export * from "./notification_mobile_push_devices"
export * from "./notifications_preferences" export * from "./notifications_preferences"
export * from "./notifications_preferences_defaults" export * from "./notifications_preferences_defaults"
export * from "./notification_push_subscriptions" export * from "./notification_push_subscriptions"
@@ -75,6 +79,9 @@ export * from "./statementallocations"
export * from "./tasks" export * from "./tasks"
export * from "./teams" export * from "./teams"
export * from "./taxtypes" export * from "./taxtypes"
export * from "./telephony_calls"
export * from "./telephony_extensions"
export * from "./telephony_trunks"
export * from "./tenants" export * from "./tenants"
export * from "./texttemplates" export * from "./texttemplates"
export * from "./units" 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 { tenants } from "./tenants"
import { units } from "./units" import { units } from "./units"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
import { costcentres } from "./costcentres"
export const services = pgTable("services", { export const services = pgTable("services", {
id: bigint("id", { mode: "number" }) id: bigint("id", { mode: "number" })
@@ -35,6 +36,8 @@ export const services = pgTable("services", {
unit: bigint("unit", { mode: "number" }).references(() => units.id), unit: bigint("unit", { mode: "number" }).references(() => units.id),
costcentre: uuid("costcentre").references(() => costcentres.id),
serviceNumber: bigint("serviceNumber", { mode: "number" }), serviceNumber: bigint("serviceNumber", { mode: "number" }),
tags: jsonb("tags").default([]), 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 portalContractRoutes from "./routes/portal/contracts";
import mcpRoutes from "./routes/mcp"; import mcpRoutes from "./routes/mcp";
import communicationRoutes from "./routes/communication"; import communicationRoutes from "./routes/communication";
import telephonyRoutes from "./routes/telephony";
import instanceAgentRoutes from "./routes/instanceAgents";
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
//Public Links //Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated"; import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -57,6 +60,7 @@ import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer" import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3"; import {initS3} from "./utils/s3";
import { runBootstrap } from "./modules/bootstrap.service"; import { runBootstrap } from "./modules/bootstrap.service";
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
//Services //Services
@@ -82,6 +86,7 @@ async function main() {
await app.register(dbPlugin); await app.register(dbPlugin);
await app.register(servicesPlugin); await app.register(servicesPlugin);
await runBootstrap(app); await runBootstrap(app);
startMatrixPushWorker(app);
app.addHook('preHandler', (req, reply, done) => { app.addHook('preHandler', (req, reply, done) => {
console.log(req.method) console.log(req.method)
@@ -125,6 +130,10 @@ async function main() {
await devicesApp.register(devicesManagementRoutes) await devicesApp.register(devicesManagementRoutes)
},{prefix: "/devices"}) },{prefix: "/devices"})
await app.register(async (agentApp) => {
await agentApp.register(instanceAgentGatewayRoutes)
},{prefix: "/instance-agent"})
await app.register(corsPlugin); await app.register(corsPlugin);
//Geschützte Routes //Geschützte Routes
@@ -154,6 +163,8 @@ async function main() {
await subApp.register(portalContractRoutes); await subApp.register(portalContractRoutes);
await subApp.register(mcpRoutes); await subApp.register(mcpRoutes);
await subApp.register(communicationRoutes); await subApp.register(communicationRoutes);
await subApp.register(telephonyRoutes);
await subApp.register(instanceAgentRoutes);
},{prefix: "/api"}) },{prefix: "/api"})

View File

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

View File

@@ -21,6 +21,7 @@ import {
texttemplates, texttemplates,
units, units,
} from "../../db/schema" } from "../../db/schema"
import { matrixService } from "./matrix.service"
const adminPermissions = [ const adminPermissions = [
"mcp.tokens.write", "mcp.tokens.write",
@@ -487,4 +488,19 @@ export async function runBootstrap(server: FastifyInstance) {
await ensureTenantBaseData(server, tenant.id, adminUser.id) await ensureTenantBaseData(server, tenant.id, adminUser.id)
console.log("✅ Bootstrap-Grunddaten geprüft") 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 { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
import { import {
authUsers, authUsers,
notificationMobilePushDevices,
notificationPushSubscriptions, notificationPushSubscriptions,
notificationsEventTypes, notificationsEventTypes,
notificationsItems, notificationsItems,
@@ -10,6 +11,7 @@ import {
notificationsPreferencesDefaults, notificationsPreferencesDefaults,
} from "../../db/schema" } from "../../db/schema"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { pushServerClient } from "./push-server.client"
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms" export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
export type NotificationStatus = "queued" | "sent" | "failed" | "read" export type NotificationStatus = "queued" | "sent" | "failed" | "read"
@@ -280,18 +282,8 @@ export class NotificationService {
} }
private async deliverPush(item: typeof notificationsItems.$inferSelect) { private async deliverPush(item: typeof notificationsItems.$inferSelect) {
if (!secrets.WEB_PUSH_PUBLIC_KEY || !secrets.WEB_PUSH_PRIVATE_KEY) { const subscriptions = secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY
await this.markFailed(item.id, "Web Push ist nicht konfiguriert") ? await this.server.db
return { success: false, id: item.id, channel: item.channel }
}
webPush.setVapidDetails(
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
secrets.WEB_PUSH_PUBLIC_KEY,
secrets.WEB_PUSH_PRIVATE_KEY
)
const subscriptions = await this.server.db
.select() .select()
.from(notificationPushSubscriptions) .from(notificationPushSubscriptions)
.where(and( .where(and(
@@ -299,8 +291,18 @@ export class NotificationService {
eq(notificationPushSubscriptions.userId, item.userId), eq(notificationPushSubscriptions.userId, item.userId),
isNull(notificationPushSubscriptions.disabledAt) 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") await this.markFailed(item.id, "Keine aktive Push-Subscription")
return { success: false, id: item.id, channel: item.channel } return { success: false, id: item.id, channel: item.channel }
} }
@@ -315,6 +317,37 @@ export class NotificationService {
let delivered = 0 let delivered = 0
const errors: string[] = [] 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) { for (const subscription of subscriptions) {
try { try {
await webPush.sendNotification({ 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, contract: item.contract,
address: item.address, address: item.address,
project: item.project, project: item.project,
costcentre: item.costcentre,
documentDate: executionDate, documentDate: executionDate,
deliveryDate: firstDate, deliveryDate: firstDate,
deliveryDateEnd: lastDate, 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) => { 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 // 1⃣ Token aus Header oder Cookie lesen
const cookieToken = req.cookies?.token const cookieToken = req.cookies?.token
const authHeader = req.headers.authorization const authHeader = req.headers.authorization
@@ -74,7 +87,7 @@ export default fp(async (server: FastifyInstance) => {
const token = const token =
headerToken && headerToken.length > 10 headerToken && headerToken.length > 10
? headerToken ? headerToken
: cookieToken || null : cookieToken || downloadToken || null
if (!token) { if (!token) {
return reply.code(401).send({ error: "Authentication required" }) return reply.code(401).send({ error: "Authentication required" })

View File

@@ -17,6 +17,8 @@ import { sendMail } from "../utils/mailer";
import { ensureTenantBaseData } from "../modules/bootstrap.service"; import { ensureTenantBaseData } from "../modules/bootstrap.service";
import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport"; import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport";
import type { TenantFullExport } 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) { export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => { const deriveNameFromEmail = (email: string) => {
@@ -393,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 // POST /admin/users
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -1018,8 +1035,27 @@ export default async function adminRoutes(server: FastifyInstance) {
}); });
} }
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 { return {
success: true, success: true,
matrixProvisioned,
matrixProvisioningError,
...result, ...result,
}; };
} catch (err: any) { } catch (err: any) {

View File

@@ -52,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
id: tenants.id, id: tenants.id,
name: tenants.name, name: tenants.name,
short: tenants.short, short: tenants.short,
calendarConfig: tenants.calendarConfig,
hasActiveLicense: tenants.hasActiveLicense, hasActiveLicense: tenants.hasActiveLicense,
locked: tenants.locked, locked: tenants.locked,
features: tenants.features, 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 (!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 (!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." }) 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." }) if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId) const hasCounterInput = Boolean(body.counterType || body.counterId)
if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." }) 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" const signedAmount = body.direction === "income"
? Math.abs(Number(body.amount)) ? 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 created = await server.db.transaction(async (tx) => {
const insertedStatements = await tx.insert(bankstatements).values({ const insertedStatements = await tx.insert(bankstatements).values({
account: cashbookId, account: cashbookId,
date: dayjs(body.date).format("YYYY-MM-DD"), date: bookingDate.format("YYYY-MM-DD"),
valueDate: bookingDate.format("YYYY-MM-DD"),
amount: signedAmount, amount: signedAmount,
tenant: req.user.tenant_id, tenant: req.user.tenant_id,
text: description, text: description,
@@ -295,18 +299,20 @@ export default async function bankingRoutes(server: FastifyInstance) {
}).returning() }).returning()
const statement = insertedStatements[0] const statement = insertedStatements[0]
const insertedAllocations = await tx.insert(statementallocations).values({ const insertedAllocations = counterPayload
bankstatement: statement.id, ? await tx.insert(statementallocations).values({
amount: signedAmount, bankstatement: statement.id,
tenant: req.user.tenant_id, amount: signedAmount,
description, tenant: req.user.tenant_id,
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null, description,
...counterPayload, datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
}).returning() ...counterPayload,
}).returning()
: []
return { return {
statement, statement,
allocation: insertedAllocations[0], allocation: insertedAllocations[0] || null,
} }
}) })
@@ -687,7 +693,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (matchesBankAccountId && matchesIban) { if (matchesBankAccountId && matchesIban) {
score = 100 score = 100
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein" reason = "IBAN und hinterlegte Bankverbindung stimmen überein"
} else if (matchesBankAccountId) { } else if (matchesBankAccountId) {
score = 95 score = 95
reason = "Hinterlegte Bankverbindung passt zur IBAN" reason = "Hinterlegte Bankverbindung passt zur IBAN"
@@ -699,7 +705,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
reason = "Name passt exakt zur Buchung" reason = "Name passt exakt zur Buchung"
} else if (partialNameMatch) { } else if (partialNameMatch) {
score = 45 score = 45
reason = "Name aehnelt der Buchung" reason = "Name ähnelt der Buchung"
} }
if (!score) continue if (!score) continue
@@ -743,7 +749,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (matchesBankAccountId && matchesIban) { if (matchesBankAccountId && matchesIban) {
score = 100 score = 100
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein" reason = "IBAN und hinterlegte Bankverbindung stimmen überein"
} else if (matchesBankAccountId) { } else if (matchesBankAccountId) {
score = 95 score = 95
reason = "Hinterlegte Bankverbindung passt zur IBAN" reason = "Hinterlegte Bankverbindung passt zur IBAN"
@@ -755,7 +761,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
reason = "Name passt exakt zur Buchung" reason = "Name passt exakt zur Buchung"
} else if (partialNameMatch) { } else if (partialNameMatch) {
score = 45 score = 45
reason = "Name aehnelt der Buchung" reason = "Name ähnelt der Buchung"
} }
if (!score) continue if (!score) continue

View File

@@ -1,9 +1,10 @@
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import multipart from "@fastify/multipart" import multipart from "@fastify/multipart"
import { and, eq, inArray, ne } from "drizzle-orm" import { and, desc, eq, inArray, ne } from "drizzle-orm"
import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema" import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema"
import { matrixService } from "../modules/matrix.service" import { matrixService } from "../modules/matrix.service"
import { getMatrixPushWorkerState } from "../modules/matrix-push-worker.service"
import { NotificationService, UserDirectory } from "../modules/notification.service" import { NotificationService, UserDirectory } from "../modules/notification.service"
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => { const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
@@ -98,6 +99,30 @@ export default async function communicationRoutes(server: FastifyInstance) {
)) ))
} }
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 getSenderName = async (tenantId: number, senderUserId: string) => {
const [sender] = await server.db const [sender] = await server.db
.select({ .select({
@@ -150,6 +175,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
if (room?.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) { if (room?.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
directRecipients.add(room.entityUuid) 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 return recipients
@@ -195,6 +224,67 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
} }
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) => { const unreadChatNotifications = async (tenantId: number, userId: string) => {
return await server.db return await server.db
.select({ .select({
@@ -359,6 +449,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.get("/communication/matrix/push-worker", async () => {
return getMatrixPushWorkerState()
})
server.get("/communication/matrix/media", async (req, reply) => { server.get("/communication/matrix/media", async (req, reply) => {
try { try {
const query = req.query as { uri?: string; name?: string } const query = req.query as { uri?: string; name?: string }
@@ -603,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) => { server.post("/communication/matrix/rooms/general/session", async (req, reply) => {
try { try {
return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, { return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, {
@@ -641,8 +744,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => { server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
try { try {
const body = req.body as { text?: string } const body = req.body as { text?: string; replyToEventId?: string }
const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "") 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") const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
await notifyUsersAboutChatMessage(req, room, message, body.text || "") await notifyUsersAboutChatMessage(req, room, message, body.text || "")
return message return message
@@ -688,7 +793,12 @@ export default async function communicationRoutes(server: FastifyInstance) {
try { try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { roomKey: string } const params = req.params as { roomKey: string }
return await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey) 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) { } catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room read state failed") return handleMatrixError(req, reply, err, "Matrix room read state failed")
} }
@@ -706,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) => { server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
try { try {
return await matrix.getTenantRoomMembers( return await matrix.getTenantRoomMembers(
@@ -718,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) => { server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => {
try { try {
return await matrix.createElementRoomSession( return await matrix.createElementRoomSession(
@@ -759,12 +932,15 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try { try {
const body = req.body as { text?: string } const body = req.body as { text?: string; replyToEventId?: string }
const message = await matrix.sendTenantRoomMessage( const message = await matrix.sendTenantRoomMessage(
req.user.user_id, req.user.user_id,
req.user.tenant_id, req.user.tenant_id,
roomOptionsFromRequest(req), roomOptionsFromRequest(req),
body.text || "" body.text || "",
{
replyToEventId: body.replyToEventId,
}
) )
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key) const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
await notifyUsersAboutChatMessage(req, room, message, body.text || "") await notifyUsersAboutChatMessage(req, room, message, body.text || "")
@@ -774,6 +950,52 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
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) => { server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => {
try { try {
const attachment = await uploadedAttachmentFromRequest(req) const attachment = await uploadedAttachmentFromRequest(req)

View File

@@ -1,17 +1,72 @@
import nodemailer from "nodemailer" import nodemailer from "nodemailer"
import { FastifyInstance } from "fastify" 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 { encrypt, decrypt } from "../utils/crypt"
import { userCredentials } from "../../db/schema" import { userCredentials } from "../../db/schema"
// Pfad ggf. anpassen import { emailSyncService } from "../modules/email/email.sync.service"
// @ts-ignore // @ts-ignore
import MailComposer from "nodemailer/lib/mail-composer/index.js" import MailComposer from "nodemailer/lib/mail-composer/index.js"
import { ImapFlow } from "imapflow" import { ImapFlow } from "imapflow"
export default async function emailAsUserRoutes(server: FastifyInstance) { 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 { const body = req.body as {
email: string email: string
password: string password: string
smtp_host: string smtpHost?: string
smtp_port: number smtpPort?: number
smtp_ssl: boolean smtpSsl?: boolean
imap_host: string imapHost?: string
imap_port: number imapPort?: number
imap_ssl: boolean imapSsl?: boolean
smtp_host?: string
smtp_port?: number
smtp_ssl?: boolean
imap_host?: string
imap_port?: number
imap_ssl?: boolean
} }
// ----------------------------- // -----------------------------
// UPDATE EXISTING // UPDATE EXISTING
// ----------------------------- // -----------------------------
if (id) { 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 = { const saveData = {
emailEncrypted: body.email ? encrypt(body.email) : undefined, emailEncrypted: body.email ? encrypt(body.email) : undefined,
passwordEncrypted: body.password ? encrypt(body.password) : undefined, passwordEncrypted: body.password ? encrypt(body.password) : undefined,
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined, smtpHostEncrypted: bodyValue(body, "smtpHost", "smtp_host") ? encrypt(bodyValue(body, "smtpHost", "smtp_host")) : undefined,
smtpPort: body.smtp_port, smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
smtpSsl: body.smtp_ssl, smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined, imapHostEncrypted: bodyValue(body, "imapHost", "imap_host") ? encrypt(bodyValue(body, "imapHost", "imap_host")) : undefined,
imapPort: body.imap_port, imapPort: bodyValue(body, "imapPort", "imap_port"),
imapSsl: body.imap_ssl, imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
updatedAt: new Date(),
} }
await server.db await server.db
.update(userCredentials) .update(userCredentials)
//@ts-ignore //@ts-ignore
.set(saveData) .set(saveData)
.where(eq(userCredentials.id, id)) .where(accountWhere(req.user.tenant_id, req.user.user_id, id))
return reply.send({ success: true }) return reply.send({ success: true })
} }
@@ -71,13 +141,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
emailEncrypted: encrypt(body.email), emailEncrypted: encrypt(body.email),
passwordEncrypted: encrypt(body.password), passwordEncrypted: encrypt(body.password),
smtpHostEncrypted: encrypt(body.smtp_host), smtpHostEncrypted: encrypt(bodyValue(body, "smtpHost", "smtp_host")),
smtpPort: body.smtp_port, smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
smtpSsl: body.smtp_ssl, smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
imapHostEncrypted: encrypt(body.imap_host), imapHostEncrypted: encrypt(bodyValue(body, "imapHost", "imap_host")),
imapPort: body.imap_port, imapPort: bodyValue(body, "imapPort", "imap_port"),
imapSsl: body.imap_ssl, imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
} }
//@ts-ignore //@ts-ignore
@@ -110,24 +180,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db const rows = await server.db
.select() .select()
.from(userCredentials) .from(userCredentials)
.where(eq(userCredentials.id, id)) .where(accountWhere(req.user.tenant_id, req.user.user_id, id))
.limit(1)
const row = rows[0] const row = rows[0]
if (!row) return reply.code(404).send({ error: "Not found" }) if (!row) return reply.code(404).send({ error: "Not found" })
const returnData: any = {} return reply.send(accountResponse(row))
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)
} }
// ============================================================ // ============================================================
@@ -136,24 +195,9 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db const rows = await server.db
.select() .select()
.from(userCredentials) .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 => { return reply.send(rows.map(accountResponse))
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)
} catch (err) { } catch (err) {
console.error("GET /email/accounts error:", err) console.error("GET /email/accounts error:", err)
@@ -183,21 +227,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const rows = await server.db const rows = await server.db
.select() .select()
.from(userCredentials) .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] const row = rows[0]
if (!row) return reply.code(404).send({ error: "Account not found" }) if (!row) return reply.code(404).send({ error: "Account not found" })
const accountData: any = {} const accountData = accountCredentials(row)
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
}
})
// ------------------------- // -------------------------
// SEND EMAIL VIA SMTP // SEND EMAIL VIA SMTP
@@ -243,14 +279,31 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const mail = new MailComposer(message) const mail = new MailComposer(message)
const raw = await mail.compile().build() const raw = await mail.compile().build()
let savedToSent = false
for await (const mailbox of await imap.list()) { for await (const mailbox of await imap.list()) {
if (mailbox.specialUse === "\\Sent") { if (mailbox.specialUse === "\\Sent") {
await imap.mailboxOpen(mailbox.path) await imap.mailboxOpen(mailbox.path)
await imap.append(mailbox.path, raw, ["\\Seen"]) 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 }) return reply.send({ success: true })
} catch (err) { } 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 { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm" import { and, eq, isNull } from "drizzle-orm"
import { authUsers } from "../../db/schema" import { authUsers, notificationMobilePushDevices } from "../../db/schema"
import { NotificationService, UserDirectory } from "../modules/notification.service" import { NotificationService, UserDirectory } from "../modules/notification.service"
import { pushServerClient } from "../modules/push-server.client"
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => { const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
const rows = await server.db 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) 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) => { server.post("/notifications/test-push", async (req) => {
return await svc.trigger({ return await svc.trigger({
tenantId: requireTenant(req.user.tenant_id), 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)}`
}

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 // 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) { if (req.tenant) {
return { return {
message: `Hallo vom Tenant ${req.tenant?.name}`, message: `Hallo vom Tenant ${req.tenant?.name}`,

View File

@@ -78,7 +78,7 @@ export const resourceConfig = {
table: contracts, table: contracts,
searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"], searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
numberRangeHolder: "contractNumber", numberRangeHolder: "contractNumber",
mtoLoad: ["customer", "contracttype", "outgoingsepamandate"], mtoLoad: ["customer", "contracttype", "contact", "outgoingsepamandate"],
}, },
outgoingsepamandates: { outgoingsepamandates: {
table: outgoingsepamandates, table: outgoingsepamandates,
@@ -230,7 +230,7 @@ export const resourceConfig = {
}, },
createddocuments: { createddocuments: {
table: 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"], mtmLoad: ["statementallocations","files","createddocuments"],
mtmListLoad: ["statementallocations", "files"], mtmListLoad: ["statementallocations", "files"],
}, },

View File

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

View File

@@ -48,6 +48,46 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
} }
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"` 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 tableColumns = async (client: any) => {
const result = await client.query(` const result = await client.query(`
@@ -346,6 +386,73 @@ const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) =>
} }
} }
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) => { const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
if (!isJsonColumn || value === null || typeof value === "undefined") return value if (!isJsonColumn || value === null || typeof value === "undefined") return value
if (typeof value === "string") return value if (typeof value === "string") return value
@@ -465,6 +572,7 @@ export const importTenantFullExport = async (
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId) const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
encryptEntityBankAccountRowsForImport(exportData) encryptEntityBankAccountRowsForImport(exportData)
prepareCommunicationRoomsForImport(exportData)
const client = await pool.connect() const client = await pool.connect()
const importOrder = [ const importOrder = [
"tenants", "tenants",
@@ -516,6 +624,11 @@ export const importTenantFullExport = async (
importedTables.push({ table, rows: count }) 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 refreshSequences(client, columnsByTable)
await client.query("commit") await client.query("commit")

View File

@@ -81,8 +81,7 @@ services:
- internal - internal
backend: backend:
build: image: git.federspiel.tech/flfeders/fedeo/backend:dev
context: ./backend
container_name: fedeo-backend container_name: fedeo-backend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -92,6 +91,8 @@ services:
condition: service_healthy condition: service_healthy
createbuckets: createbuckets:
condition: service_completed_successfully condition: service_completed_successfully
matrix-synapse:
condition: service_healthy
environment: environment:
NODE_ENV: production NODE_ENV: production
FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true} FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true}
@@ -100,7 +101,7 @@ services:
COOKIE_SECRET: ${COOKIE_SECRET} COOKIE_SECRET: ${COOKIE_SECRET}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY} 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_HOST: ${MAILER_SMTP_HOST}
MAILER_SMTP_PORT: ${MAILER_SMTP_PORT} MAILER_SMTP_PORT: ${MAILER_SMTP_PORT}
MAILER_SMTP_SSL: ${MAILER_SMTP_SSL} MAILER_SMTP_SSL: ${MAILER_SMTP_SSL}
@@ -112,6 +113,11 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY} S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET} 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} M2M_API_KEY: ${M2M_API_KEY}
API_BASE_URL: ${API_BASE_URL} API_BASE_URL: ${API_BASE_URL}
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL} GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
@@ -130,6 +136,7 @@ services:
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer} FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer}
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO} FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-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_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://matrix-synapse:8008}
MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}} MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}}
MATRIX_RTC_HOST: ${MATRIX_RTC_HOST:-${DOMAIN}} MATRIX_RTC_HOST: ${MATRIX_RTC_HOST:-${DOMAIN}}
@@ -139,6 +146,7 @@ services:
MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service} MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service}
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit} LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace} LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`) - traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
@@ -152,9 +160,25 @@ services:
- web - web
- internal - 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: frontend:
build: image: git.federspiel.tech/flfeders/fedeo/frontend:dev
context: ./frontend
container_name: fedeo-frontend container_name: fedeo-frontend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -169,6 +193,7 @@ services:
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) - traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
- traefik.http.routers.fedeo-frontend.entrypoints=websecure - traefik.http.routers.fedeo-frontend.entrypoints=websecure
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt - 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.http.services.fedeo-frontend.loadbalancer.server.port=3000
- traefik.docker.network=fedeo_web - traefik.docker.network=fedeo_web
networks: networks:
@@ -279,6 +304,12 @@ services:
exec /start.py exec /start.py
volumes: volumes:
- ./matrix/synapse:/data - ./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: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.fedeo-matrix.rule=Host(`${DOMAIN}`) && PathPrefix(`/_matrix`) - traefik.http.routers.fedeo-matrix.rule=Host(`${DOMAIN}`) && PathPrefix(`/_matrix`)
@@ -426,6 +457,7 @@ services:
matrix-element: matrix-element:
image: vectorim/element-web:latest image: vectorim/element-web:latest
container_name: fedeo-matrix-element container_name: fedeo-matrix-element
user: "0:0"
restart: unless-stopped restart: unless-stopped
entrypoint: /bin/sh entrypoint: /bin/sh
command: command:

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_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-}
- WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-} - WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-}
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com} - 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: networks:
- traefik - traefik
labels: labels:
@@ -74,6 +90,23 @@ services:
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" # - "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge" - "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip" - "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: matrix-db:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped

View File

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

@@ -400,6 +400,32 @@ const canArchiveItem = computed(() => {
return true return true
}) })
const telephonyExtensionTarget = computed(() => {
if (!item.value?.id) return null
if (type === "teams") {
return {
targetType: "team",
targetId: item.value.id,
displayName: item.value.name || "Team",
title: "Telefonie",
description: "Lege fest, unter welcher Nebenstelle dieses Team erreichbar ist."
}
}
if (type === "branches") {
return {
targetType: "branch",
targetId: item.value.id,
displayName: item.value.name || "Niederlassung",
title: "Telefonie",
description: "Lege fest, unter welcher Nebenstelle diese Niederlassung erreichbar ist."
}
}
return null
})
const createItem = async () => { const createItem = async () => {
let ret = null let ret = null
@@ -1036,6 +1062,16 @@ const updateItem = async () => {
</InputGroup> </InputGroup>
</UFormField> </UFormField>
</UForm> </UForm>
<TelephonyExtensionField
v-if="telephonyExtensionTarget"
class="mx-5 mb-5"
:target-type="telephonyExtensionTarget.targetType"
:target-id="telephonyExtensionTarget.targetId"
:display-name="telephonyExtensionTarget.displayName"
:title="telephonyExtensionTarget.title"
:description="telephonyExtensionTarget.description"
/>
</UDashboardPanelContent> </UDashboardPanelContent>
</template> </template>

View File

@@ -60,6 +60,22 @@ const renderDatapointValue = (datapoint) => {
return `${value}${datapoint.unit ? datapoint.unit : ""}` return `${value}${datapoint.unit ? datapoint.unit : ""}`
} }
const isTelephonyDatapoint = (datapoint) => {
if (datapoint.telephonyCall) return true
const key = String(datapoint.key || "").toLowerCase()
return [
"phone",
"tel",
"mobile",
"mobiletel",
"phonemobile",
"phonehome",
"fixed_tel",
"mobile_tel",
].some((part) => key.includes(part))
}
</script> </script>
<template> <template>
@@ -84,6 +100,12 @@ const renderDatapointValue = (datapoint) => {
<td>{{datapoint.label}}:</td> <td>{{datapoint.label}}:</td>
<td> <td>
<component v-if="datapoint.component" :is="datapoint.component" :row="props.item" :in-show="true"></component> <component v-if="datapoint.component" :is="datapoint.component" :row="props.item" :in-show="true"></component>
<div v-else-if="isTelephonyDatapoint(datapoint) && getDatapointValue(datapoint)" class="flex flex-wrap items-center gap-2">
<TelephonyCallButton
:number="getDatapointValue(datapoint)"
:label="renderDatapointValue(datapoint)"
/>
</div>
<div v-else> <div v-else>
<span>{{ renderDatapointValue(datapoint) }}</span> <span>{{ renderDatapointValue(datapoint) }}</span>
</div> </div>

View File

@@ -0,0 +1,324 @@
<script setup>
const props = defineProps({
scanData: {
type: Object,
default: () => ({
folder: null,
type: null,
typeEnabled: true
})
}
})
const emit = defineEmits(["scanFinished"])
const modal = useModal()
const toast = useToast()
const { $api } = useNuxtApp()
const loadingAgents = ref(true)
const scanInProgress = ref(false)
const agents = ref([])
const selectedAgentId = ref(null)
const currentJob = ref(null)
const statusMessage = ref("")
const scanForm = reactive({
filename: `scan-${new Date().toISOString().slice(0, 10)}.pdf`,
scannerName: "",
format: "pdf",
resolution: 300,
mode: "Color",
source: "ADF Duplex",
postprocess: true,
postprocessProfile: "receipt"
})
const activeAgents = computed(() =>
agents.value.filter((agent) => agent.active && agent.capabilities?.scan)
)
const selectedAgent = computed(() =>
agents.value.find((agent) => agent.id === selectedAgentId.value) || null
)
const scannerOptions = computed(() =>
(selectedAgent.value?.scannerNames || []).map((scanner) => ({
label: scanner,
value: scanner
}))
)
const isOnline = (agent) => {
if (!agent.lastSeenAt) return false
return Date.now() - new Date(agent.lastSeenAt).getTime() < 2 * 60 * 1000
}
const applyAgentDefaults = (agent) => {
if (!agent) return
scanForm.scannerName = agent.preferredScannerName || agent.scannerNames?.[0] || ""
scanForm.format = agent.scanDefaults?.format || "pdf"
scanForm.resolution = Number(agent.scanDefaults?.resolution || 300)
scanForm.mode = agent.scanDefaults?.mode || "Color"
scanForm.source = agent.scanDefaults?.source || "ADF Duplex"
scanForm.postprocess = agent.scanDefaults?.postprocess !== false
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "receipt"
if (!scanForm.filename || scanForm.filename.startsWith("scan-")) {
scanForm.filename = `scan-${new Date().toISOString().slice(0, 10)}.${scanForm.format || "pdf"}`
}
}
watch(selectedAgent, (agent) => applyAgentDefaults(agent))
const loadAgents = async () => {
loadingAgents.value = true
try {
const response = await $api("/api/instance-agents")
agents.value = response?.agents || []
const firstOnlineAgent = activeAgents.value.find(isOnline)
selectedAgentId.value = firstOnlineAgent?.id || activeAgents.value[0]?.id || null
applyAgentDefaults(selectedAgent.value)
} catch (err) {
toast.add({
title: "Scanner konnten nicht geladen werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error"
})
} finally {
loadingAgents.value = false
}
}
const waitForJob = async (jobId) => {
for (let i = 0; i < 90; i++) {
await new Promise((resolve) => setTimeout(resolve, 2000))
const response = await $api(`/api/scan-jobs/${jobId}`)
const job = response?.job
currentJob.value = job
if (job?.status === "completed") return job
if (job?.status === "failed" || job?.status === "canceled") {
throw new Error(job.agentMessage || "Scan-Auftrag wurde abgebrochen.")
}
statusMessage.value = job?.status === "running"
? "Scanner arbeitet..."
: "Warte auf den Agenten..."
}
throw new Error("Der Scan-Auftrag läuft länger als erwartet.")
}
const startScan = async () => {
if (!selectedAgentId.value) return
scanInProgress.value = true
statusMessage.value = "Scan-Auftrag wird erstellt..."
currentJob.value = null
try {
const response = await $api("/api/scan-jobs", {
method: "POST",
body: {
agentId: selectedAgentId.value,
scannerName: scanForm.scannerName || null,
requestedFilename: scanForm.filename || null,
settings: {
format: scanForm.format || "pdf",
resolution: Number(scanForm.resolution || 300),
mode: scanForm.mode || "Color",
source: scanForm.source || null,
postprocess: scanForm.postprocess,
postprocessProfile: scanForm.postprocessProfile
},
target: {
folder: props.scanData.folder || null,
type: props.scanData.type || null
}
}
})
currentJob.value = response?.job
if (!response?.job?.id) {
throw new Error("FEDEO hat keinen Scan-Auftrag zurückgegeben.")
}
statusMessage.value = "Warte auf den Agenten..."
await waitForJob(response.job.id)
toast.add({
title: "Scan gespeichert",
description: props.scanData.folder ? "Die Datei wurde im aktuellen Ordner abgelegt." : "Die Datei wurde in Dateien abgelegt.",
color: "success"
})
emit("scanFinished")
modal.close()
} catch (err) {
toast.add({
title: "Scan fehlgeschlagen",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error"
})
statusMessage.value = err?.message || "Scan fehlgeschlagen"
} finally {
scanInProgress.value = false
}
}
loadAgents()
</script>
<template>
<UModal>
<template #content>
<UCard class="shadow-xl ring-1 ring-black/5">
<template #header>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary ring-1 ring-primary/15">
<UIcon name="i-heroicons-document-magnifying-glass" class="h-5 w-5" />
</div>
<div>
<h3 class="text-base font-semibold text-highlighted">Dokument scannen</h3>
<p class="text-xs text-muted">Der Scan wird im aktuellen Ordner gespeichert.</p>
</div>
</div>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
:disabled="scanInProgress"
@click="modal.close()"
/>
</div>
</template>
<div class="space-y-4">
<UAlert
v-if="!loadingAgents && activeAgents.length === 0"
color="warning"
variant="soft"
icon="i-heroicons-exclamation-triangle"
title="Kein aktiver Scan-Agent"
description="Lege in der Administration einen Scanner-Agenten an und starte den lokalen Agenten."
/>
<UFormField label="Scanner-Agent">
<USelectMenu
v-model="selectedAgentId"
:items="activeAgents.map((agent) => ({ label: agent.name, value: agent.id, suffix: isOnline(agent) ? 'Online' : 'Offline' }))"
value-key="value"
label-key="label"
placeholder="Agent wählen"
:loading="loadingAgents"
class="w-full"
:disabled="scanInProgress"
/>
</UFormField>
<UFormField label="Scanner">
<USelectMenu
v-if="scannerOptions.length"
v-model="scanForm.scannerName"
:items="scannerOptions"
value-key="value"
label-key="label"
placeholder="Standard-Scanner"
class="w-full"
:disabled="scanInProgress"
/>
<UInput
v-else
v-model="scanForm.scannerName"
placeholder="Standard-Scanner"
:disabled="scanInProgress"
/>
</UFormField>
<UFormField label="Dateiname">
<UInput v-model="scanForm.filename" :disabled="scanInProgress" />
</UFormField>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Quelle">
<UInput v-model="scanForm.source" placeholder="ADF Duplex" :disabled="scanInProgress" />
</UFormField>
<UFormField label="Auflösung">
<UInput v-model.number="scanForm.resolution" type="number" min="75" step="25" :disabled="scanInProgress" />
</UFormField>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Modus">
<UInput v-model="scanForm.mode" placeholder="Color" :disabled="scanInProgress" />
</UFormField>
<UFormField label="Format">
<USelectMenu
v-model="scanForm.format"
:items="[
{ label: 'PDF', value: 'pdf' },
{ label: 'PNG', value: 'png' },
{ label: 'TIFF', value: 'tiff' }
]"
value-key="value"
label-key="label"
:disabled="scanInProgress"
/>
</UFormField>
</div>
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
<UCheckbox
v-model="scanForm.postprocess"
label="OpenCV-Korrektur"
:disabled="scanInProgress"
/>
<UFormField label="Profil">
<USelectMenu
v-model="scanForm.postprocessProfile"
:items="[
{ label: 'Bon', value: 'receipt' },
{ label: 'Dokument', value: 'document' },
{ label: 'Rohscan', value: 'raw' }
]"
value-key="value"
label-key="label"
:disabled="scanInProgress || !scanForm.postprocess"
/>
</UFormField>
</div>
<UAlert
v-if="statusMessage"
color="info"
variant="soft"
icon="i-heroicons-clock"
:title="statusMessage"
:description="currentJob?.status ? `Status: ${currentJob.status}` : undefined"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" :disabled="scanInProgress" @click="modal.close()">Abbrechen</UButton>
<UButton
icon="i-heroicons-document-magnifying-glass"
:loading="scanInProgress"
:disabled="!selectedAgentId || loadingAgents"
@click="startScan"
>
Scannen
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>

View File

@@ -80,6 +80,11 @@ const links = computed(() => {
to: "/communication/chat", to: "/communication/chat",
icon: "i-heroicons-chat-bubble-left-right" icon: "i-heroicons-chat-bubble-left-right"
}, },
{
label: "Telefonie",
to: "/communication/phone",
icon: "i-heroicons-phone"
},
featureEnabled("helpdesk") ? { featureEnabled("helpdesk") ? {
label: "Helpdesk", label: "Helpdesk",
to: "/helpdesk", to: "/helpdesk",
@@ -88,9 +93,8 @@ const links = computed(() => {
} : null, } : null,
featureEnabled("email") ? { featureEnabled("email") ? {
label: "E-Mail", label: "E-Mail",
to: "/email/new", to: "/email",
icon: "i-heroicons-envelope", icon: "i-heroicons-envelope",
disabled: true
} : null, } : null,
] ]
@@ -332,6 +336,11 @@ const links = computed(() => {
to: "/settings/tenant", to: "/settings/tenant",
icon: "i-heroicons-building-office", icon: "i-heroicons-building-office",
} : null, } : null,
{
label: "Telefonie",
to: "/settings/telephony",
icon: "i-heroicons-phone",
},
{ {
label: "Matrix-Setup", label: "Matrix-Setup",
to: "/communication", to: "/communication",
@@ -355,6 +364,16 @@ const links = computed(() => {
to: "/administration/tenants", to: "/administration/tenants",
icon: "i-heroicons-building-office-2", icon: "i-heroicons-building-office-2",
}, },
{
label: "Scanner",
to: "/administration/scanners",
icon: "i-heroicons-printer",
},
{
label: "Systemstatus",
to: "/administration/system",
icon: "i-heroicons-server-stack",
},
] : [] ] : []
const visibleOrganisationChildren = visibleItems(organisationChildren) const visibleOrganisationChildren = visibleItems(organisationChildren)

View File

@@ -0,0 +1,51 @@
<script setup>
const props = defineProps({
number: {
type: [String, Number],
default: ""
},
label: {
type: String,
default: ""
},
size: {
type: String,
default: "xs"
},
variant: {
type: String,
default: "soft"
}
})
const router = useRouter()
const normalizedNumber = computed(() => String(props.number || "").trim())
const displayLabel = computed(() => props.label || normalizedNumber.value)
const canCall = computed(() => normalizedNumber.value.length > 0)
const openSoftphone = async () => {
if (!canCall.value) return
await router.push({
path: "/communication/phone",
query: {
call: normalizedNumber.value,
name: props.label || undefined
}
})
}
</script>
<template>
<UButton
icon="i-heroicons-phone"
color="primary"
:size="size"
:variant="variant"
:disabled="!canCall"
@click="openSoftphone"
>
{{ displayLabel }}
</UButton>
</template>

View File

@@ -0,0 +1,70 @@
<script setup>
const {
incomingCall,
incomingCaller,
selectedExtension,
sipLoading,
acceptIncomingCall,
rejectIncomingCall,
} = useTelephonySoftphone()
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="-translate-y-3 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="-translate-y-3 opacity-0"
>
<div
v-if="incomingCall"
class="fixed inset-x-3 top-3 z-[1000] mx-auto max-w-2xl rounded-lg border border-primary-200 bg-white p-4 shadow-xl ring-1 ring-primary-100 sm:top-5"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-3">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary-50 text-primary-600 ring-8 ring-primary-100">
<UIcon name="i-heroicons-phone-arrow-down-left" class="h-6 w-6" />
</div>
<div class="min-w-0">
<p class="text-xs font-medium uppercase text-primary-600">
Eingehender Anruf
</p>
<h2 class="truncate text-lg font-semibold text-gray-950">
{{ incomingCaller }}
</h2>
<p class="mt-1 text-xs text-gray-500">
Nebenstelle {{ selectedExtension }}
</p>
</div>
</div>
<div class="grid gap-2 sm:min-w-64 sm:grid-cols-2">
<UButton
color="success"
icon="i-heroicons-phone"
size="lg"
block
:loading="sipLoading"
@click="acceptIncomingCall"
>
Annehmen
</UButton>
<UButton
color="error"
variant="soft"
icon="i-heroicons-phone-x-mark"
size="lg"
block
@click="rejectIncomingCall"
>
Ablehnen
</UButton>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,197 @@
<script setup>
const props = defineProps({
targetType: {
type: String,
required: true
},
targetId: {
type: [String, Number],
default: null
},
displayName: {
type: String,
default: ""
},
title: {
type: String,
default: "Telefonie"
},
description: {
type: String,
default: "Lege fest, unter welcher internen Nebenstelle dieses Ziel erreichbar ist."
}
})
const emit = defineEmits(["saved"])
const toast = useToast()
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const extension = ref("")
const enabled = ref(true)
const existingId = ref(null)
const canUseExtension = computed(() => Boolean(props.targetId))
const statusLabel = computed(() => {
if (!canUseExtension.value) return "Kein Ziel verknüpft"
if (!extension.value) return "Keine Nebenstelle"
return enabled.value ? `Nebenstelle ${extension.value}` : `Nebenstelle ${extension.value} inaktiv`
})
const statusColor = computed(() => {
if (!canUseExtension.value) return "neutral"
if (!extension.value) return "neutral"
return enabled.value ? "success" : "warning"
})
const loadExtension = async () => {
if (!canUseExtension.value) return
loading.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/extensions/target", {
params: {
targetType: props.targetType,
targetId: props.targetId
}
})
existingId.value = res?.extension?.id || null
extension.value = res?.extension?.extension || ""
enabled.value = res?.extension ? res.extension.enabled !== false : true
} catch (error) {
toast.add({
title: "Nebenstelle konnte nicht geladen werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
loading.value = false
}
}
const saveExtension = async () => {
if (!canUseExtension.value || saving.value) return
saving.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/extensions/target", {
method: "PUT",
body: {
targetType: props.targetType,
targetId: props.targetId,
extension: extension.value,
displayName: props.displayName,
enabled: enabled.value
}
})
existingId.value = res?.id || existingId.value
extension.value = res?.extension || extension.value
enabled.value = res?.enabled !== false
toast.add({ title: "Nebenstelle gespeichert", color: "success" })
emit("saved", res)
} catch (error) {
toast.add({
title: "Nebenstelle konnte nicht gespeichert werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
saving.value = false
}
}
const deleteExtension = async () => {
if (!canUseExtension.value || deleting.value) return
deleting.value = true
try {
await useNuxtApp().$api("/api/telephony/extensions/target", {
method: "DELETE",
params: {
targetType: props.targetType,
targetId: props.targetId
}
})
existingId.value = null
extension.value = ""
enabled.value = true
toast.add({ title: "Nebenstelle entfernt", color: "success" })
emit("saved", null)
} catch (error) {
toast.add({
title: "Nebenstelle konnte nicht entfernt werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
deleting.value = false
}
}
watch(() => [props.targetType, props.targetId], loadExtension, { immediate: true })
</script>
<template>
<UCard>
<template #header>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold text-highlighted">
{{ title }}
</h3>
<p class="mt-1 text-sm text-muted">
{{ description }}
</p>
</div>
<UBadge :color="statusColor" variant="soft">
{{ statusLabel }}
</UBadge>
</div>
</template>
<UAlert
v-if="!canUseExtension"
color="warning"
icon="i-heroicons-exclamation-triangle"
title="Noch nicht verfügbar"
description="Speichere den Datensatz zuerst oder verknüpfe einen Benutzer, bevor eine Nebenstelle vergeben wird."
/>
<div v-else class="grid gap-4 md:grid-cols-[1fr_auto] md:items-end">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<UFormField label="Nebenstelle">
<UInput
v-model="extension"
inputmode="tel"
placeholder="z. B. 1001"
:loading="loading"
/>
</UFormField>
<UFormField label="Aktiv">
<USwitch v-model="enabled" />
</UFormField>
</div>
<div class="flex flex-wrap gap-2 md:justify-end">
<UButton
icon="i-heroicons-check"
:loading="saving"
:disabled="!extension"
@click="saveExtension"
>
Speichern
</UButton>
<UButton
v-if="existingId"
icon="i-heroicons-trash"
color="error"
variant="soft"
:loading="deleting"
@click="deleteExtension"
>
Entfernen
</UButton>
</div>
</div>
</UCard>
</template>

View File

@@ -54,6 +54,7 @@ const optionsToImport = ref({
contactPerson: true, contactPerson: true,
plant: true, plant: true,
project:true, project:true,
costcentre: true,
description: true, description: true,
startText: false, startText: false,
rows: true, rows: true,
@@ -74,6 +75,7 @@ const mappings = ref({
contactPerson: "Ansprechpartner Mitarbeiter", contactPerson: "Ansprechpartner Mitarbeiter",
plant: "Objekt", plant: "Objekt",
project: "Projekt", project: "Projekt",
costcentre: "Kostenstelle",
description: "Beschreibung", description: "Beschreibung",
startText: "Einleitung", startText: "Einleitung",
rows: "Positionen", rows: "Positionen",

View File

@@ -10,6 +10,7 @@ const props = defineProps({
const loading = ref(true) const loading = ref(true)
const incomingInvoices = ref([]) const incomingInvoices = ref([])
const createddocuments = ref([])
const costcentres = ref([]) const costcentres = ref([])
const selectedYear = ref(String(dayjs().year())) const selectedYear = ref(String(dayjs().year()))
const selectedMonth = ref("all") const selectedMonth = ref("all")
@@ -98,7 +99,7 @@ const monthItems = [
] ]
const reportRows = computed(() => { const reportRows = computed(() => {
return incomingInvoices.value.flatMap((invoice) => { const incomingRows = incomingInvoices.value.flatMap((invoice) => {
const invoiceDate = invoice.date ? dayjs(invoice.date) : null const invoiceDate = invoice.date ? dayjs(invoice.date) : null
if (invoiceDate && invoiceDate.year().toString() !== selectedYear.value) { if (invoiceDate && invoiceDate.year().toString() !== selectedYear.value) {
@@ -120,7 +121,8 @@ const reportRows = computed(() => {
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre)) const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
return { return {
id: `${invoice.id}-${index}`, id: `incoming-${invoice.id}-${index}`,
sourceLabel: "Eingangsbeleg",
invoiceId: invoice.id, invoiceId: invoice.id,
reference: invoice.reference || "-", reference: invoice.reference || "-",
date: invoice.date, date: invoice.date,
@@ -135,6 +137,53 @@ const reportRows = computed(() => {
} }
}) })
}) })
const outgoingRows = createddocuments.value.flatMap((document) => {
const documentDate = document.documentDate ? dayjs(document.documentDate) : null
if (documentDate && documentDate.year().toString() !== selectedYear.value) {
return []
}
if (documentDate && selectedMonth.value !== "all" && documentDate.month() + 1 !== Number(selectedMonth.value)) {
return []
}
return (document.rows || [])
.filter((row) => !["pagebreak", "title", "text"].includes(row.mode))
.map((row, index) => {
const costCentreId = getCostCentreId(row.costCentre || row.costcentre || document.costcentre)
if (!relevantCostCentreIds.value.has(costCentreId)) {
return null
}
const amountNet = Number((Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(2))
const taxPercent = Number(row.taxPercent || 0)
const amountTax = Number((amountNet * (taxPercent / 100)).toFixed(2))
const amountGross = Number((amountNet + amountTax).toFixed(2))
const accountCostCentre = costCentreMap.value.get(costCentreId)
return {
id: `outgoing-${document.id}-${index}`,
sourceLabel: "Ausgangsbeleg",
invoiceId: document.id,
reference: document.documentNumber || document.title || "-",
date: document.documentDate,
state: document.state || "-",
vendorName: document.customer?.name || "-",
accountLabel: "Umsatz",
costCentreName: accountCostCentre ? `${accountCostCentre.number} - ${accountCostCentre.name}` : "-",
description: row.text || row.description || document.description || "-",
amountNet,
amountTax,
amountGross
}
})
.filter(Boolean)
})
return [...incomingRows, ...outgoingRows]
}) })
const totals = computed(() => { const totals = computed(() => {
@@ -147,9 +196,10 @@ const totals = computed(() => {
}) })
const columns = [ const columns = [
{ accessorKey: "sourceLabel", header: "Art" },
{ accessorKey: "reference", header: "Beleg" }, { accessorKey: "reference", header: "Beleg" },
{ accessorKey: "date", header: "Datum" }, { accessorKey: "date", header: "Datum" },
{ accessorKey: "vendorName", header: "Lieferant" }, { accessorKey: "vendorName", header: "Kontakt" },
{ accessorKey: "accountLabel", header: "Konto" }, { accessorKey: "accountLabel", header: "Konto" },
{ accessorKey: "costCentreName", header: "Kostenstelle" }, { accessorKey: "costCentreName", header: "Kostenstelle" },
{ accessorKey: "description", header: "Beschreibung" }, { accessorKey: "description", header: "Beschreibung" },
@@ -163,10 +213,16 @@ const setupPage = async () => {
costcentres.value = await useEntities("costcentres").select("*", null, false, true) costcentres.value = await useEntities("costcentres").select("*", null, false, true)
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)") const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
const documents = await useEntities("createddocuments").select("*, customer(id,name)")
incomingInvoices.value = invoices.filter((invoice) => incomingInvoices.value = invoices.filter((invoice) =>
(invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre)) (invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre))
) )
createddocuments.value = documents.filter((document) =>
["invoices", "advanceInvoices", "cancellationInvoices"].includes(document.type)
&& document.state === "Gebucht"
&& (document.rows || []).some((row) => relevantCostCentreIds.value.has(getCostCentreId(row.costCentre || row.costcentre || document.costcentre)))
)
const firstYear = yearItems.value[0]?.value const firstYear = yearItems.value[0]?.value
if (firstYear && !yearItems.value.some((item) => item.value === selectedYear.value)) { if (firstYear && !yearItems.value.some((item) => item.value === selectedYear.value)) {
@@ -264,7 +320,7 @@ setupPage()
<div class="text-right font-medium tabular-nums">{{ currency(row.original.amountGross) }}</div> <div class="text-right font-medium tabular-nums">{{ currency(row.original.amountGross) }}</div>
</template> </template>
<template #empty> <template #empty>
<TableEmptyState label="Keine Eingangsbelege mit dieser Kostenstelle oder ihren Unterkostenstellen gefunden" /> <TableEmptyState label="Keine Belege mit dieser Kostenstelle oder ihren Unterkostenstellen gefunden" />
</template> </template>
</UTable> </UTable>

View File

@@ -55,11 +55,67 @@ export type TenantImportResult = {
files: { restored: number; skipped: number } files: { restored: number; skipped: number }
} }
export type SystemStatus = {
checkedAt: string
backend: {
status: string
uptimeSeconds: number
nodeVersion: string
environment: string
}
server: {
status: string
nodeExporterUrl: string
error?: string | null
hostname?: string | null
kernel?: string | null
cpuCount?: number | null
uptimeSeconds?: number | null
load: { one?: number | null; five?: number | null; fifteen?: number | null }
memory: { totalBytes?: number | null; availableBytes?: number | null; usedBytes?: number | null; usedPercent?: number | null }
disk: { rootTotalBytes?: number | null; rootAvailableBytes?: number | null; rootUsedBytes?: number | null; rootUsedPercent?: number | null }
}
services: Record<string, {
ok: boolean
status: string
error?: string | null
[key: string]: any
}>
}
export type InstanceAgent = {
id: string
createdAt: string
updatedAt: string
name: string
description?: string | null
tokenPrefix: string
active: boolean
capabilities: Record<string, any>
scannerNames: string[]
printerNames: string[]
preferredScannerName?: string | null
scanDefaults: {
format?: string
resolution?: number
mode?: string
source?: string | null
[key: string]: any
}
lastSeenAt?: string | null
lastDebugInfo?: Record<string, any> | null
}
export type CreateInstanceAgentResult = {
agent: InstanceAgent
token: string
}
export const useAdmin = () => { export const useAdmin = () => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const getOverview = async (): Promise<AdminOverview> => { const getOverview = async (): Promise<AdminOverview> => {
const response = await $api("/api/admin/overview") const response = await $api("/api/admin/overview") as any
return { return {
users: response?.users || [], users: response?.users || [],
@@ -130,8 +186,35 @@ export const useAdmin = () => {
}) })
} }
const getSystemStatus = async (): Promise<SystemStatus> => {
return await $api("/api/admin/system-status")
}
const getInstanceAgents = async (): Promise<{ agents: InstanceAgent[] }> => {
const response = await $api("/api/instance-agents") as any
return { agents: response?.agents || [] }
}
const createInstanceAgent = async (body: Record<string, any>): Promise<CreateInstanceAgentResult> => {
return await $api("/api/instance-agents", {
method: "POST",
body,
})
}
const updateInstanceAgent = async (id: string, body: Record<string, any>): Promise<{ agent: InstanceAgent }> => {
return await $api(`/api/instance-agents/${id}`, {
method: "PATCH",
body,
})
}
return { return {
getOverview, getOverview,
getSystemStatus,
getInstanceAgents,
createInstanceAgent,
updateInstanceAgent,
createUser, createUser,
createUserForProfile, createUserForProfile,
updateUser, updateUser,

View File

@@ -0,0 +1,722 @@
const loading = ref(false)
const statusLoading = ref(false)
const websocketTesting = ref(false)
const callHistoryLoading = ref(false)
const config = ref(null)
const status = ref(null)
const websocketResult = ref(null)
const callHistory = ref([])
const lastUpdated = ref(null)
const selectedExtension = ref("1001")
const dialTarget = ref("600")
const sipModule = ref(null)
const userAgent = shallowRef(null)
const registerer = shallowRef(null)
const activeSession = shallowRef(null)
const remoteAudio = ref(null)
const sipLoading = ref(false)
const sipRegistered = ref(false)
const sipStatus = ref("Nicht verbunden")
const sipError = ref(null)
const incomingCall = ref(null)
const callState = ref("idle")
const registererState = ref("Initial")
const sipEvents = ref([])
const ringtoneTimer = shallowRef(null)
const ringtoneAudioContext = shallowRef(null)
const callSignalStatus = ref("Bereit")
const originalDocumentTitle = ref("")
const activeCallRecordId = ref(null)
const activeCallDirection = ref(null)
const activeCallAnsweredAt = ref(null)
export const useTelephonySoftphone = () => {
const toast = useToast()
const { $api } = useNuxtApp()
const incomingCaller = computed(() => {
const identity = incomingCall.value?.remoteIdentity
return identity?.displayName || identity?.uri?.user || "Unbekannter Anrufer"
})
const selectedAccount = computed(() =>
(config.value?.accounts || []).find((account) => account.extension === selectedExtension.value)
|| (config.value?.accounts || [])[0]
)
const canRegisterSip = computed(() =>
Boolean(config.value?.sipWebSocketUrl && config.value?.sipDomain && selectedAccount.value && !sipLoading.value)
)
const canStartCall = computed(() =>
Boolean(sipRegistered.value && dialTarget.value?.trim() && !activeSession.value && !sipLoading.value)
)
const canHangup = computed(() =>
Boolean(activeSession.value && callState.value !== "terminated")
)
const statusColor = computed(() => {
if (status.value?.reachable) return "success"
if (!status.value?.enabled) return "warning"
return "error"
})
const statusIcon = computed(() => {
if (status.value?.reachable) return "i-heroicons-signal"
if (!status.value?.enabled) return "i-heroicons-pause-circle"
return "i-heroicons-signal-slash"
})
const websocketColor = computed(() => {
if (!websocketResult.value) return "neutral"
return websocketResult.value.ok ? "success" : "error"
})
const addSipEvent = (message) => {
sipEvents.value = [
{
time: new Date().toLocaleTimeString("de-DE"),
message,
},
...sipEvents.value,
].slice(0, 8)
}
const sipCallIdFromSession = (session) =>
session?.request?.callId
|| session?.request?.message?.callId
|| session?.incomingInviteRequest?.message?.callId
|| session?.outgoingInviteRequest?.message?.callId
|| null
const loadCallHistory = async (filters = {}) => {
callHistoryLoading.value = true
try {
callHistory.value = await $api("/api/telephony/calls", {
params: {
limit: 50,
...filters,
}
})
} catch (error) {
addSipEvent(`Anrufhistorie konnte nicht geladen werden: ${error?.message || "Unbekannter Fehler"}`)
} finally {
callHistoryLoading.value = false
}
}
const createCallRecord = async ({ direction, status: nextStatus, localExtension, remoteNumber, remoteDisplayName, session }) => {
try {
const call = await $api("/api/telephony/calls", {
method: "POST",
body: {
direction,
status: nextStatus,
localExtension,
remoteNumber,
remoteDisplayName,
sipCallId: sipCallIdFromSession(session),
startedAt: new Date().toISOString(),
},
})
activeCallRecordId.value = call.id
activeCallDirection.value = direction
activeCallAnsweredAt.value = null
await loadCallHistory()
return call
} catch (error) {
addSipEvent(`Anrufhistorie konnte nicht geschrieben werden: ${error?.message || "Unbekannter Fehler"}`)
return null
}
}
const updateCallRecord = async (id, payload) => {
if (!id) return null
try {
const call = await $api(`/api/telephony/calls/${id}`, {
method: "PATCH",
body: payload,
})
await loadCallHistory()
return call
} catch (error) {
addSipEvent(`Anrufhistorie konnte nicht aktualisiert werden: ${error?.message || "Unbekannter Fehler"}`)
return null
}
}
const markCallAnswered = async () => {
if (!activeCallRecordId.value || activeCallAnsweredAt.value) return
activeCallAnsweredAt.value = new Date()
await updateCallRecord(activeCallRecordId.value, {
status: "active",
answeredAt: activeCallAnsweredAt.value.toISOString(),
})
}
const finalizeCallRecord = async (statusOverride = null) => {
if (!activeCallRecordId.value) return
const id = activeCallRecordId.value
const direction = activeCallDirection.value
const answeredAt = activeCallAnsweredAt.value
const endedAt = new Date()
const finalStatus = statusOverride
|| (answeredAt ? "completed" : direction === "incoming" ? "missed" : "canceled")
activeCallRecordId.value = null
activeCallDirection.value = null
activeCallAnsweredAt.value = null
await updateCallRecord(id, {
status: finalStatus,
endedAt: endedAt.toISOString(),
})
}
const ensureSipModule = async () => {
if (!sipModule.value) {
sipModule.value = await import("sip.js")
}
return sipModule.value
}
const cleanupRemoteAudio = () => {
if (!remoteAudio.value?.srcObject) return
const stream = remoteAudio.value.srcObject
if (stream?.getTracks) {
stream.getTracks().forEach((track) => track.stop())
}
remoteAudio.value.srcObject = null
}
const attachRemoteAudio = async (session) => {
const peerConnection = session?.sessionDescriptionHandler?.peerConnection
if (!peerConnection || !remoteAudio.value) return
const remoteStream = new MediaStream()
peerConnection.getReceivers().forEach((receiver) => {
if (receiver.track) remoteStream.addTrack(receiver.track)
})
remoteAudio.value.srcObject = remoteStream
try {
await remoteAudio.value.play()
} catch (error) {
addSipEvent(`Audioausgabe blockiert: ${error?.message || "Browser-Autoplay"}`)
}
}
const playRingtoneTick = async () => {
if (!window.AudioContext && !window.webkitAudioContext) return
const AudioContextConstructor = window.AudioContext || window.webkitAudioContext
const context = ringtoneAudioContext.value || new AudioContextConstructor()
ringtoneAudioContext.value = context
if (context.state === "suspended") {
await context.resume()
}
const oscillator = context.createOscillator()
const gain = context.createGain()
oscillator.type = "sine"
oscillator.frequency.value = 880
gain.gain.setValueAtTime(0.0001, context.currentTime)
gain.gain.exponentialRampToValueAtTime(0.12, context.currentTime + 0.03)
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.35)
oscillator.connect(gain)
gain.connect(context.destination)
oscillator.start()
oscillator.stop(context.currentTime + 0.38)
}
const stopCallSignaling = () => {
if (ringtoneTimer.value) {
window.clearInterval(ringtoneTimer.value)
ringtoneTimer.value = null
}
if (originalDocumentTitle.value) {
document.title = originalDocumentTitle.value
originalDocumentTitle.value = ""
}
if (navigator.vibrate) {
navigator.vibrate(0)
}
callSignalStatus.value = "Bereit"
}
const startCallSignaling = async () => {
stopCallSignaling()
callSignalStatus.value = "Signalisiert"
originalDocumentTitle.value = document.title
document.title = "Eingehender Anruf - FEDEO"
if (navigator.vibrate) {
navigator.vibrate([180, 90, 180])
}
try {
await playRingtoneTick()
ringtoneTimer.value = window.setInterval(() => {
playRingtoneTick().catch((error) => {
callSignalStatus.value = "Klingelton blockiert"
addSipEvent(`Klingelton blockiert: ${error?.message || "Browser-Autoplay"}`)
})
}, 1400)
} catch (error) {
callSignalStatus.value = "Klingelton blockiert"
addSipEvent(`Klingelton blockiert: ${error?.message || "Browser-Autoplay"}`)
}
}
const setupSession = (session, direction = "outgoing") => {
const { SessionState } = sipModule.value
activeSession.value = session
callState.value = direction === "incoming" ? "incoming" : "connecting"
sipStatus.value = direction === "incoming" ? "Eingehender Anruf" : "Anruf wird aufgebaut"
session.stateChange.addListener(async (state) => {
if (state === SessionState.Establishing) {
callState.value = "connecting"
sipStatus.value = "Anruf wird aufgebaut"
}
if (state === SessionState.Established) {
stopCallSignaling()
callState.value = "active"
sipStatus.value = "Im Gespräch"
incomingCall.value = null
await markCallAnswered()
await attachRemoteAudio(session)
}
if (state === SessionState.Terminated) {
await finalizeCallRecord()
stopCallSignaling()
callState.value = "terminated"
sipStatus.value = sipRegistered.value ? "Registriert" : "Nicht verbunden"
activeSession.value = null
incomingCall.value = null
cleanupRemoteAudio()
}
})
}
const loadTelephony = async () => {
loading.value = true
try {
const [configRes, statusRes] = await Promise.all([
$api("/api/telephony/config"),
$api("/api/telephony/status")
])
config.value = configRes
status.value = statusRes
lastUpdated.value = new Date()
const accounts = configRes?.accounts || []
if (!selectedAccount.value && accounts.length) {
selectedExtension.value = accounts[0].extension
}
await loadCallHistory()
} catch (error) {
toast.add({
title: "Telefonie-Status konnte nicht geladen werden",
color: "error"
})
} finally {
loading.value = false
}
}
const refreshStatus = async () => {
statusLoading.value = true
try {
status.value = await $api("/api/telephony/status")
lastUpdated.value = new Date()
} catch (error) {
toast.add({
title: "Asterisk-Status konnte nicht geprüft werden",
color: "error"
})
} finally {
statusLoading.value = false
}
}
const testWebSocket = async () => {
if (!config.value?.sipWebSocketUrl || websocketTesting.value) return
websocketTesting.value = true
websocketResult.value = null
await new Promise((resolve) => {
let settled = false
const socket = new WebSocket(config.value.sipWebSocketUrl, "sip")
const timer = window.setTimeout(() => {
if (settled) return
settled = true
socket.close()
websocketResult.value = {
ok: false,
message: "WebSocket-Verbindung ist abgelaufen."
}
resolve()
}, 3000)
socket.onopen = () => {
if (settled) return
settled = true
window.clearTimeout(timer)
websocketResult.value = {
ok: true,
message: "SIP-WebSocket ist aus dem Browser erreichbar."
}
socket.close()
resolve()
}
socket.onerror = () => {
if (settled) return
settled = true
window.clearTimeout(timer)
websocketResult.value = {
ok: false,
message: "SIP-WebSocket konnte nicht geöffnet werden."
}
resolve()
}
})
websocketTesting.value = false
}
const stopSip = async () => {
sipLoading.value = true
sipError.value = null
try {
if (activeSession.value) {
await hangupCall()
}
if (registerer.value) {
await registerer.value.unregister()
registerer.value = null
}
if (userAgent.value) {
await userAgent.value.stop()
userAgent.value = null
}
sipRegistered.value = false
registererState.value = "Initial"
sipStatus.value = "Nicht verbunden"
callState.value = "idle"
} catch (error) {
sipError.value = error?.message || "SIP-Verbindung konnte nicht getrennt werden."
} finally {
sipLoading.value = false
}
}
const registerSip = async () => {
if (!canRegisterSip.value) return
sipLoading.value = true
sipError.value = null
try {
await stopSip()
const sip = await ensureSipModule()
const account = selectedAccount.value
const uri = sip.UserAgent.makeURI(`sip:${account.extension}@${config.value.sipDomain}`)
if (!uri) throw new Error("SIP-URI konnte nicht erstellt werden.")
const handleIncomingInvite = (invitation) => {
const remoteNumber = invitation.remoteIdentity?.uri?.user || null
addSipEvent(`INVITE von ${invitation.remoteIdentity?.uri?.user || "unbekannt"} empfangen`)
incomingCall.value = invitation
setupSession(invitation, "incoming")
void createCallRecord({
direction: "incoming",
status: "ringing",
localExtension: account.extension,
remoteNumber,
remoteDisplayName: invitation.remoteIdentity?.displayName || remoteNumber,
session: invitation,
})
startCallSignaling()
toast.add({
title: "Eingehender Anruf",
description: `Anruf für ${account.extension}`,
color: "primary"
})
}
const ua = new sip.UserAgent({
uri,
displayName: account.displayName,
contactName: account.extension,
authorizationUsername: account.extension,
authorizationPassword: account.password,
logBuiltinEnabled: true,
logLevel: "log",
logConnector: (level, category, label, content) => {
if (category === "sip.Transport" && content.includes("Received WebSocket")) {
addSipEvent("SIP-Nachricht über WebSocket empfangen")
}
if (content.includes("INVITE")) {
addSipEvent(`${category}: ${content.split("\n")[0]}`)
}
if (level === "error" || level === "warn") {
addSipEvent(`${category}: ${content.split("\n")[0]}`)
}
},
delegate: {
onInvite: handleIncomingInvite,
onConnect: () => {
addSipEvent("SIP-WebSocket verbunden")
sipStatus.value = sipRegistered.value ? `Registriert als ${account.extension}` : "SIP-WebSocket verbunden"
},
onDisconnect: () => {
addSipEvent("SIP-WebSocket getrennt")
sipRegistered.value = false
sipStatus.value = "SIP-WebSocket getrennt"
},
},
transportOptions: {
server: config.value.sipWebSocketUrl,
keepAliveInterval: 20,
traceSip: true,
},
sessionDescriptionHandlerFactoryOptions: {
constraints: {
audio: true,
video: false,
},
peerConnectionConfiguration: {
iceServers: [],
},
},
})
const reg = new sip.Registerer(ua, {
expires: 120,
})
reg.stateChange.addListener((state) => {
registererState.value = state
if (state === sip.RegistererState.Registered) {
addSipEvent(`Registrierung ${account.extension}: Registered`)
sipRegistered.value = true
sipStatus.value = `Registriert als ${account.extension}`
}
if (state === sip.RegistererState.Unregistered || state === sip.RegistererState.Terminated) {
addSipEvent(`Registrierung ${account.extension}: ${state}`)
sipRegistered.value = false
sipStatus.value = "Nicht verbunden"
}
})
userAgent.value = ua
registerer.value = reg
await ua.start()
await reg.register({
requestDelegate: {
onAccept: () => {
addSipEvent(`REGISTER ${account.extension}: 200 OK`)
sipRegistered.value = true
sipStatus.value = `Registriert als ${account.extension}`
registererState.value = sip.RegistererState.Registered
},
onReject: (response) => {
sipRegistered.value = false
sipStatus.value = "Registrierung abgelehnt"
sipError.value = `Asterisk hat REGISTER mit HTTP/SIP ${response.message.statusCode} abgelehnt.`
addSipEvent(`REGISTER ${account.extension}: ${response.message.statusCode}`)
},
},
})
} catch (error) {
sipRegistered.value = false
sipStatus.value = "Nicht verbunden"
sipError.value = error?.message || "SIP-Registrierung fehlgeschlagen."
} finally {
sipLoading.value = false
}
}
const startCall = async () => {
if (!canStartCall.value) return
sipLoading.value = true
sipError.value = null
try {
const sip = await ensureSipModule()
const target = sip.UserAgent.makeURI(`sip:${dialTarget.value.trim()}@${config.value.sipDomain}`)
if (!target) throw new Error("Zielnummer ist keine gültige SIP-Adresse.")
const inviter = new sip.Inviter(userAgent.value, target, {
sessionDescriptionHandlerOptions: {
constraints: {
audio: true,
video: false,
},
},
})
await createCallRecord({
direction: "outgoing",
status: "dialing",
localExtension: selectedAccount.value?.extension || selectedExtension.value,
remoteNumber: dialTarget.value.trim(),
remoteDisplayName: dialTarget.value.trim(),
session: inviter,
})
setupSession(inviter, "outgoing")
await inviter.invite()
} catch (error) {
await finalizeCallRecord("failed")
activeSession.value = null
callState.value = "idle"
sipError.value = error?.message || "Anruf konnte nicht gestartet werden."
} finally {
sipLoading.value = false
}
}
const acceptIncomingCall = async () => {
if (!incomingCall.value) return
sipLoading.value = true
sipError.value = null
stopCallSignaling()
try {
await incomingCall.value.accept({
sessionDescriptionHandlerOptions: {
constraints: {
audio: true,
video: false,
},
},
})
} catch (error) {
sipError.value = error?.message || "Anruf konnte nicht angenommen werden."
} finally {
sipLoading.value = false
}
}
const rejectIncomingCall = async () => {
if (!incomingCall.value) return
try {
stopCallSignaling()
await incomingCall.value.reject()
await finalizeCallRecord("rejected")
} finally {
incomingCall.value = null
activeSession.value = null
callState.value = "idle"
}
}
const hangupCall = async () => {
const session = activeSession.value
if (!session) return
const { SessionState } = sipModule.value || await ensureSipModule()
try {
if (session.state === SessionState.Initial && session.reject) {
await session.reject()
} else if (session.state === SessionState.Establishing && session.cancel) {
await session.cancel()
} else if (session.state === SessionState.Established && session.bye) {
await session.bye()
} else if (session.dispose) {
session.dispose()
}
} finally {
await finalizeCallRecord()
stopCallSignaling()
activeSession.value = null
incomingCall.value = null
callState.value = "idle"
cleanupRemoteAudio()
sipStatus.value = sipRegistered.value ? "Registriert" : "Nicht verbunden"
}
}
return {
loading,
statusLoading,
websocketTesting,
callHistoryLoading,
config,
status,
websocketResult,
callHistory,
lastUpdated,
selectedExtension,
dialTarget,
activeSession,
remoteAudio,
sipLoading,
sipRegistered,
sipStatus,
sipError,
incomingCall,
incomingCaller,
callState,
registererState,
sipEvents,
callSignalStatus,
selectedAccount,
canRegisterSip,
canStartCall,
canHangup,
statusColor,
statusIcon,
websocketColor,
loadTelephony,
loadCallHistory,
refreshStatus,
testWebSocket,
registerSip,
stopSip,
startCall,
acceptIncomingCall,
rejectIncomingCall,
hangupCall,
}
}

View File

@@ -311,6 +311,7 @@ onMounted(() => {
</UDashboardPanel> </UDashboardPanel>
</UDashboardGroup> </UDashboardGroup>
<TelephonyCallOverlay/>
<HelpSlideover/> <HelpSlideover/>
<Calculator v-if="calculatorStore.isOpen"/> <Calculator v-if="calculatorStore.isOpen"/>

View File

@@ -1,6 +1,5 @@
export default defineNuxtRouteMiddleware(async (to, from) => { export default defineNuxtRouteMiddleware(async (to, from) => {
const auth = useAuthStore() const auth = useAuthStore()
const isPortalUser = Boolean(auth.profile?.customer_for_portal)
// DEBUG: Was sieht die Middleware wirklich? // DEBUG: Was sieht die Middleware wirklich?
console.log("🔒 Middleware Check auf:", to.path) console.log("🔒 Middleware Check auf:", to.path)
@@ -13,6 +12,19 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
return return
} }
if (process.server) {
return
}
const token = auth.getStoredToken()
if (!auth.user && token) {
console.log("🔄 Auth-Bootstrap aus Cookie")
await auth.initStore()
}
const isPortalUser = Boolean(auth.profile?.customer_for_portal)
if (auth.loading) { if (auth.loading) {
console.log("⏳ Auth lädt noch...") console.log("⏳ Auth lädt noch...")
return return

View File

@@ -83,6 +83,7 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"sass": "^1.69.7", "sass": "^1.69.7",
"sip.js": "^0.21.2",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"tailwindcss-safe-area-capacitor": "^0.5.1", "tailwindcss-safe-area-capacitor": "^0.5.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
@@ -19022,6 +19023,15 @@
"url": "https://github.com/steveukx/git-js?sponsor=1" "url": "https://github.com/steveukx/git-js?sponsor=1"
} }
}, },
"node_modules/sip.js": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/sip.js/-/sip.js-0.21.2.tgz",
"integrity": "sha512-tSqTcIgrOd2IhP/rd70JablvAp+fSfLSxO4hGNY6LkWRY1SKygTO7OtJEV/BQb8oIxtMRx0LE7nUF2MaqGbFzA==",
"license": "MIT",
"engines": {
"node": ">=10.0"
}
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",

View File

@@ -96,6 +96,7 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"sass": "^1.69.7", "sass": "^1.69.7",
"sip.js": "^0.21.2",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"tailwindcss-safe-area-capacitor": "^0.5.1", "tailwindcss-safe-area-capacitor": "^0.5.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",

View File

@@ -0,0 +1,429 @@
<script setup lang="ts">
import type { InstanceAgent } from "~/composables/useAdmin"
const auth = useAuthStore()
const router = useRouter()
const toast = useToast()
const admin = useAdmin()
const loading = ref(true)
const saving = ref(false)
const creating = ref(false)
const agents = ref<InstanceAgent[]>([])
const selectedAgentId = ref<string | null>(null)
const generatedToken = ref("")
const newAgent = reactive({
name: "",
description: "",
})
const editState = reactive({
name: "",
description: "",
active: true,
preferredScannerName: "",
format: "pdf",
resolution: 300,
mode: "Color",
source: "",
postprocess: false,
postprocessProfile: "document",
})
const selectedAgent = computed(() =>
agents.value.find((agent) => agent.id === selectedAgentId.value) || null
)
const scannerOptions = computed(() => {
const names = selectedAgent.value?.scannerNames || []
return names.map((scanner) => ({
label: scanner,
value: scanner,
}))
})
const isOnline = (agent: InstanceAgent) => {
if (!agent.lastSeenAt) return false
return Date.now() - new Date(agent.lastSeenAt).getTime() < 2 * 60 * 1000
}
const formatDate = (value?: string | null) => {
if (!value) return "-"
return new Date(value).toLocaleString("de-DE")
}
const applyAgentToForm = (agent: InstanceAgent | null) => {
if (!agent) return
editState.name = agent.name || ""
editState.description = agent.description || ""
editState.active = Boolean(agent.active)
editState.preferredScannerName = agent.preferredScannerName || ""
editState.format = agent.scanDefaults?.format || "pdf"
editState.resolution = Number(agent.scanDefaults?.resolution || 300)
editState.mode = agent.scanDefaults?.mode || "Color"
editState.source = agent.scanDefaults?.source || ""
editState.postprocess = Boolean(agent.scanDefaults?.postprocess)
editState.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
}
watch(selectedAgent, (agent) => applyAgentToForm(agent), { immediate: true })
const loadAgents = async () => {
loading.value = true
try {
const response = await admin.getInstanceAgents()
agents.value = response.agents
if (!selectedAgentId.value && agents.value[0]) {
selectedAgentId.value = agents.value[0].id
}
if (selectedAgent.value) applyAgentToForm(selectedAgent.value)
} catch (err: any) {
console.error("[administration/scanners]", err)
toast.add({
title: "Scanner konnten nicht geladen werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error",
})
} finally {
loading.value = false
}
}
const createAgent = async () => {
if (!newAgent.name.trim()) return
creating.value = true
generatedToken.value = ""
try {
const response = await admin.createInstanceAgent({
name: newAgent.name.trim(),
description: newAgent.description.trim() || null,
})
generatedToken.value = response.token
newAgent.name = ""
newAgent.description = ""
selectedAgentId.value = response.agent.id
await loadAgents()
toast.add({
title: "Agent angelegt",
description: "Der Token wird nur jetzt vollständig angezeigt.",
color: "success",
})
} catch (err: any) {
toast.add({
title: "Agent konnte nicht angelegt werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error",
})
} finally {
creating.value = false
}
}
const saveAgent = async () => {
if (!selectedAgent.value) return
saving.value = true
try {
await admin.updateInstanceAgent(selectedAgent.value.id, {
name: editState.name.trim(),
description: editState.description.trim() || null,
active: editState.active,
preferredScannerName: editState.preferredScannerName || null,
scanDefaults: {
format: editState.format,
resolution: Number(editState.resolution || 300),
mode: editState.mode,
source: editState.source || null,
postprocess: editState.postprocess,
postprocessProfile: editState.postprocessProfile,
},
})
await loadAgents()
toast.add({
title: "Scanner-Einstellungen gespeichert",
color: "success",
})
} catch (err: any) {
toast.add({
title: "Scanner-Einstellungen konnten nicht gespeichert werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error",
})
} finally {
saving.value = false
}
}
onMounted(async () => {
if (!auth.user?.is_admin) {
await router.push("/")
return
}
await loadAgents()
})
</script>
<template>
<UDashboardNavbar title="Administration: Scanner">
<template #right>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="outline"
:loading="loading"
@click="loadAgents"
>
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div class="grid min-h-0 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div class="space-y-4">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-plus-circle" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Agent anlegen</h2>
</div>
</template>
<form class="space-y-3" @submit.prevent="createAgent">
<UFormField label="Name">
<UInput v-model="newAgent.name" placeholder="MacBook Büro" />
</UFormField>
<UFormField label="Beschreibung">
<UTextarea v-model="newAgent.description" :rows="2" placeholder="Arbeitsplatz oder Standort" />
</UFormField>
<UButton
type="submit"
icon="i-heroicons-plus"
:loading="creating"
:disabled="!newAgent.name.trim()"
>
Agent anlegen
</UButton>
</form>
</UCard>
<UAlert
v-if="generatedToken"
color="warning"
variant="soft"
icon="i-heroicons-key"
title="Agent-Token"
description="Diesen Token jetzt in die .env des lokalen Agenten übernehmen. Später wird er nicht erneut angezeigt."
>
<template #description>
<code class="mt-2 block break-all rounded bg-muted px-2 py-1 text-xs">{{ generatedToken }}</code>
</template>
</UAlert>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-computer-desktop" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Agenten</h2>
</div>
<UBadge variant="soft">{{ agents.length }}</UBadge>
</div>
</template>
<div class="space-y-2">
<button
v-for="agent in agents"
:key="agent.id"
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-default px-3 py-2 text-left transition hover:bg-muted"
:class="selectedAgentId === agent.id ? 'border-primary bg-primary/5' : ''"
@click="selectedAgentId = agent.id"
>
<span
class="size-2 rounded-full"
:class="isOnline(agent) ? 'bg-success' : 'bg-muted'"
/>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-highlighted">{{ agent.name }}</span>
<span class="block truncate text-xs text-muted">{{ agent.scannerNames?.length || 0 }} Scanner · {{ formatDate(agent.lastSeenAt) }}</span>
</span>
<UBadge :color="agent.active ? 'success' : 'neutral'" variant="soft" size="xs">
{{ agent.active ? "Aktiv" : "Inaktiv" }}
</UBadge>
</button>
</div>
</UCard>
</div>
<div v-if="selectedAgent" class="space-y-4">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold text-highlighted">{{ selectedAgent.name }}</h2>
<p class="text-xs text-muted">Token-Präfix {{ selectedAgent.tokenPrefix }} · zuletzt gesehen {{ formatDate(selectedAgent.lastSeenAt) }}</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="isOnline(selectedAgent) ? 'success' : 'neutral'" variant="soft">
{{ isOnline(selectedAgent) ? "Online" : "Offline" }}
</UBadge>
<UBadge :color="selectedAgent.active ? 'success' : 'neutral'" variant="soft">
{{ selectedAgent.active ? "Aktiv" : "Inaktiv" }}
</UBadge>
</div>
</div>
</template>
<div class="grid gap-6 lg:grid-cols-2">
<div class="space-y-4">
<UFormField label="Name">
<UInput v-model="editState.name" />
</UFormField>
<UFormField label="Beschreibung">
<UTextarea v-model="editState.description" :rows="3" />
</UFormField>
<UCheckbox v-model="editState.active" label="Agent aktiv" />
</div>
<div class="space-y-4">
<UFormField label="Standard-Scanner">
<USelectMenu
v-if="scannerOptions.length"
v-model="editState.preferredScannerName"
:items="scannerOptions"
value-key="value"
label-key="label"
placeholder="Scanner wählen"
/>
<UInput
v-else
v-model="editState.preferredScannerName"
placeholder="Scannername"
/>
</UFormField>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Format">
<USelectMenu
v-model="editState.format"
:items="[
{ label: 'PDF', value: 'pdf' },
{ label: 'PNG', value: 'png' },
{ label: 'TIFF', value: 'tiff' },
]"
value-key="value"
label-key="label"
/>
</UFormField>
<UFormField label="Auflösung">
<UInput v-model.number="editState.resolution" type="number" min="75" step="25" />
</UFormField>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Modus">
<UInput v-model="editState.mode" placeholder="Color" />
</UFormField>
<UFormField label="Quelle">
<UInput v-model="editState.source" placeholder="ADF Duplex" />
</UFormField>
</div>
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
<UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" />
<UFormField label="Profil">
<USelectMenu
v-model="editState.postprocessProfile"
:items="[
{ label: 'Dokument', value: 'document' },
{ label: 'Bon', value: 'receipt' },
{ label: 'Rohscan', value: 'raw' },
]"
value-key="value"
label-key="label"
:disabled="!editState.postprocess"
/>
</UFormField>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<UButton icon="i-heroicons-check" :loading="saving" @click="saveAgent">
Speichern
</UButton>
</div>
</template>
</UCard>
<div class="grid gap-4 lg:grid-cols-2">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-queue-list" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Erkannte Scanner</h2>
</div>
</template>
<div v-if="selectedAgent.scannerNames?.length" class="space-y-2">
<div
v-for="scanner in selectedAgent.scannerNames"
:key="scanner"
class="rounded border border-default px-3 py-2 text-sm text-highlighted"
>
{{ scanner }}
</div>
</div>
<p v-else class="text-sm text-muted">Noch keine Scanner vom Agenten gemeldet.</p>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-information-circle" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Agent-Info</h2>
</div>
</template>
<dl class="space-y-3 text-sm">
<div class="flex justify-between gap-3">
<dt class="text-muted">Plattform</dt>
<dd class="text-highlighted">{{ selectedAgent.capabilities?.platform || "-" }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted">Scan</dt>
<dd class="text-highlighted">{{ selectedAgent.capabilities?.scan ? "Ja" : "Nein" }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted">Druck</dt>
<dd class="text-highlighted">{{ selectedAgent.capabilities?.print ? "Ja" : "Nein" }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted">Hostname</dt>
<dd class="truncate text-highlighted">{{ selectedAgent.lastDebugInfo?.hostname || "-" }}</dd>
</div>
</dl>
</UCard>
</div>
</div>
<UCard v-else :ui="{ root: 'rounded-lg' }">
<div class="py-10 text-center text-muted">
Noch kein Scanner-Agent angelegt.
</div>
</UCard>
</div>
</UDashboardPanelContent>
</template>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import type { SystemStatus } from "~/composables/useAdmin"
const auth = useAuthStore()
const toast = useToast()
const router = useRouter()
const admin = useAdmin()
const loading = ref(true)
const status = ref<SystemStatus | null>(null)
const serviceLabels: Record<string, string> = {
backend: "Backend",
database: "Datenbank",
nodeExporter: "Node Exporter",
matrix: "Matrix",
minio: "Dateispeicher",
}
const formatBytes = (value?: number | null) => {
const bytes = Number(value || 0)
if (!bytes) return "-"
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
}
const formatDuration = (seconds?: number | null) => {
const value = Number(seconds || 0)
if (!value) return "-"
const days = Math.floor(value / 86400)
const hours = Math.floor((value % 86400) / 3600)
const minutes = Math.floor((value % 3600) / 60)
if (days) return `${days} d ${hours} h`
if (hours) return `${hours} h ${minutes} min`
return `${minutes} min`
}
const loadStatus = async () => {
loading.value = true
try {
status.value = await admin.getSystemStatus()
} catch (err: any) {
console.error("[administration/system]", err)
toast.add({
title: "Systemstatus konnte nicht geladen werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red",
})
} finally {
loading.value = false
}
}
const serviceRows = computed(() => {
const services = status.value?.services || {}
return Object.entries(services).map(([key, service]) => ({
key,
label: serviceLabels[key] || key,
...service,
}))
})
const overallStatus = computed(() => {
if (!status.value) return "unavailable"
return serviceRows.value.every((service) => service.ok) ? "ok" : "warning"
})
onMounted(async () => {
if (!auth.user?.is_admin) {
await router.push("/")
return
}
await loadStatus()
})
</script>
<template>
<UDashboardNavbar title="Administration: Systemstatus">
<template #right>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="outline"
:loading="loading"
@click="loadStatus"
>
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<div class="space-y-6">
<UAlert
:icon="overallStatus === 'ok' ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
:color="overallStatus === 'ok' ? 'success' : 'warning'"
variant="soft"
:title="overallStatus === 'ok' ? 'System läuft' : 'System prüfen'"
:description="status?.checkedAt ? `Letzte Prüfung: ${new Date(status.checkedAt).toLocaleString('de-DE')}` : 'Noch keine Prüfung geladen.'"
/>
<div class="grid gap-4 xl:grid-cols-3">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-cpu-chip" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Server</h2>
</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex justify-between gap-3">
<span class="text-muted">Host</span>
<span class="truncate text-highlighted">{{ status?.server.hostname || "-" }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">CPU</span>
<span class="text-highlighted">{{ status?.server.cpuCount || "-" }} Kerne</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Load</span>
<span class="text-highlighted">
{{ status?.server.load.one ?? "-" }} · {{ status?.server.load.five ?? "-" }} · {{ status?.server.load.fifteen ?? "-" }}
</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Uptime</span>
<span class="text-highlighted">{{ formatDuration(status?.server.uptimeSeconds) }}</span>
</div>
</div>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-circle-stack" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Speicher</h2>
</div>
</template>
<div class="space-y-4 text-sm">
<div>
<div class="mb-1 flex justify-between">
<span class="text-muted">RAM</span>
<span class="text-highlighted">{{ status?.server.memory.usedPercent ?? "-" }}%</span>
</div>
<UProgress :model-value="status?.server.memory.usedPercent || 0" />
<p class="mt-1 text-xs text-muted">
{{ formatBytes(status?.server.memory.usedBytes) }} von {{ formatBytes(status?.server.memory.totalBytes) }}
</p>
</div>
<div>
<div class="mb-1 flex justify-between">
<span class="text-muted">Root-Dateisystem</span>
<span class="text-highlighted">{{ status?.server.disk.rootUsedPercent ?? "-" }}%</span>
</div>
<UProgress :model-value="status?.server.disk.rootUsedPercent || 0" />
<p class="mt-1 text-xs text-muted">
{{ formatBytes(status?.server.disk.rootUsedBytes) }} von {{ formatBytes(status?.server.disk.rootTotalBytes) }}
</p>
</div>
</div>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-server-stack" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Backend</h2>
</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex justify-between gap-3">
<span class="text-muted">Status</span>
<UBadge color="success" variant="soft">{{ status?.backend.status || "-" }}</UBadge>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Laufzeit</span>
<span class="text-highlighted">{{ formatDuration(status?.backend.uptimeSeconds) }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Node.js</span>
<span class="text-highlighted">{{ status?.backend.nodeVersion || "-" }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted">Umgebung</span>
<span class="text-highlighted">{{ status?.backend.environment || "-" }}</span>
</div>
</div>
</UCard>
</div>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-signal" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Dienste</h2>
</div>
</template>
<div class="divide-y divide-default">
<div
v-for="service in serviceRows"
:key="service.key"
class="flex items-center justify-between gap-4 py-3"
>
<div class="min-w-0">
<p class="font-medium text-highlighted">{{ service.label }}</p>
<p class="truncate text-xs text-muted">
{{ service.error || service.url || service.publicBaseUrl || service.status }}
</p>
</div>
<UBadge
:color="service.ok ? 'success' : 'error'"
variant="soft"
>
{{ service.ok ? "OK" : "Fehler" }}
</UBadge>
</div>
</div>
</UCard>
</div>
</UDashboardPanelContent>
</template>

View File

@@ -22,11 +22,16 @@ const incominginvoices = ref([])
const openDocuments = ref([]) const openDocuments = ref([])
const openIncomingInvoices = ref([]) const openIncomingInvoices = ref([])
const filterAccount = ref([]) const filterAccount = ref([])
const filterAccountInitialized = ref(false)
const isSyncing = ref(false) const isSyncing = ref(false)
const cashbookBookingModalOpen = ref(false)
const savingCashbookBooking = ref(false)
const loadingDocs = ref(true) // Startet im Ladezustand const loadingDocs = ref(true) // Startet im Ladezustand
const suggestionsModalOpen = ref(false) const suggestionsModalOpen = ref(false)
const selectedSuggestionRowId = ref(null) const selectedSuggestionRowId = ref(null)
const CASHBOOK_BANK_ID = "fedeo-cashbook"
// Zeitraum-Optionen // Zeitraum-Optionen
const periodOptions = [ const periodOptions = [
{label: 'Aktueller Monat', key: 'current_month'}, {label: 'Aktueller Monat', key: 'current_month'},
@@ -52,6 +57,12 @@ const dateRange = ref({
end: $dayjs().endOf('month').format('YYYY-MM-DD') end: $dayjs().endOf('month').format('YYYY-MM-DD')
}) })
const cashbookForm = reactive({
date: $dayjs().format("YYYY-MM-DD"),
direction: "expense",
amount: null
})
const getCalendarValue = (value) => { const getCalendarValue = (value) => {
if (!value) return undefined if (!value) return undefined
@@ -72,7 +83,7 @@ const setDateRangeFieldToToday = (field) => {
const setupPage = async () => { const setupPage = async () => {
loadingDocs.value = true loadingDocs.value = true
try { try {
const [statements, accounts, customerItems, vendorItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([ const [statements, bankAccountItems, customerItems, vendorItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false), useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
useEntities("bankaccounts").select(), useEntities("bankaccounts").select(),
useEntities("customers").select(), useEntities("customers").select(),
@@ -83,7 +94,10 @@ const setupPage = async () => {
]) ])
bankstatements.value = statements bankstatements.value = statements
bankaccounts.value = accounts bankaccounts.value = (bankAccountItems || []).map((account) => ({
...account,
displayLabel: getBankAccountLabel(account)
}))
customers.value = customerItems customers.value = customerItems
vendors.value = vendorItems vendors.value = vendorItems
entitybankaccounts.value = entityBankItems entitybankaccounts.value = entityBankItems
@@ -102,8 +116,14 @@ const setupPage = async () => {
openIncomingInvoices.value = invoiceItems openIncomingInvoices.value = invoiceItems
.filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false)) .filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
if (bankaccounts.value.length > 0 && filterAccount.value.length === 0) { const availableAccountIds = bankaccounts.value.map((account) => account.id)
filterAccount.value = bankaccounts.value if (!filterAccountInitialized.value) {
filterAccount.value = availableAccountIds
filterAccountInitialized.value = true
} else {
filterAccount.value = filterAccount.value
.map((account) => getBankAccountId(account))
.filter((id) => availableAccountIds.includes(id))
} }
// Erst nach dem Laden der Daten die Store-Werte anwenden // Erst nach dem Laden der Daten die Store-Werte anwenden
@@ -179,7 +199,7 @@ const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] || ['Nur of
const shouldShowMonthDivider = (row, index) => { const shouldShowMonthDivider = (row, index) => {
if (index === 0) return true; if (index === 0) return true;
const prevRow = filteredRows.value[index - 1]; const prevRow = filteredRows.value[index - 1];
return $dayjs(row.valueDate).format('MMMM YYYY') !== $dayjs(prevRow.valueDate).format('MMMM YYYY'); return $dayjs(getStatementDate(row)).format('MMMM YYYY') !== $dayjs(getStatementDate(prevRow)).format('MMMM YYYY');
} }
const calculateOpenSum = (statement) => { const calculateOpenSum = (statement) => {
@@ -205,6 +225,63 @@ const getInvoiceSum = (invoice, onlyOpenSum) => {
} }
} }
const getStatementDate = (statement) => statement.valueDate || statement.date
const isCashbookAccount = (account) => account?.bankId === CASHBOOK_BANK_ID
const getBankAccountId = (account) => typeof account === "object" ? account?.id : account
const resolveBankAccount = (accountOrId) => {
if (!accountOrId) return null
if (typeof accountOrId === "object") return accountOrId
return bankaccounts.value.find((account) => account.id === accountOrId) || null
}
const getBankAccountLabel = (account) => {
if (!account) return ""
if (isCashbookAccount(account)) return `${account.name || account.ownerName || "Kassenbuch"} - Konto ${account.datevNumber || account.iban}`
return `${account.name || account.ownerName || "Bankkonto"} - ${account.iban}`
}
const selectedAccountLabel = computed(() => {
if (filterAccount.value.length === 0) return "Keine Konten"
if (filterAccount.value.length === bankaccounts.value.length) return "Alle Konten"
if (filterAccount.value.length === 1) return getBankAccountLabel(resolveBankAccount(filterAccount.value[0]))
return `${filterAccount.value.length} Konten`
})
const selectedCashbookAccount = computed(() => {
if (filterAccount.value.length !== 1) return null
const account = resolveBankAccount(filterAccount.value[0])
return isCashbookAccount(account) ? account : null
})
const currentCashbookBalance = computed(() => {
if (!selectedCashbookAccount.value) return 0
const openingBalance = Number(selectedCashbookAccount.value.balance || 0)
const movementSum = bankstatements.value
.filter((statement) => statement.account === selectedCashbookAccount.value.id)
.reduce((sum, statement) => sum + Number(statement.amount || 0), 0)
return openingBalance + movementSum
})
const saveCashbookBooking = async () => {
if (!selectedCashbookAccount.value || !cashbookForm.date || !cashbookForm.amount) {
toast.add({ title: "Bitte Kasse, Datum und Betrag auswählen.", color: "warning" })
return
}
savingCashbookBooking.value = true
try {
const created = await $api(`/api/banking/cashbooks/${selectedCashbookAccount.value.id}/bookings`, {
method: "POST",
body: {
date: cashbookForm.date,
direction: cashbookForm.direction,
amount: Number(cashbookForm.amount)
}
})
toast.add({ title: "Kassenbuchung erstellt." })
cashbookForm.amount = null
cashbookBookingModalOpen.value = false
await router.push(`/banking/statements/edit/${created.statement.id}`)
} finally {
savingCashbookBooking.value = false
}
}
const normalizeIban = (value) => String(value || "").replace(/\s+/g, "").toUpperCase() const normalizeIban = (value) => String(value || "").replace(/\s+/g, "").toUpperCase()
const normalizeSuggestionText = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9äöüß]+/gi, " ").replace(/\s+/g, " ").trim() const normalizeSuggestionText = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9äöüß]+/gi, " ").replace(/\s+/g, " ").trim()
const getSuggestionTokens = (value) => [...new Set(normalizeSuggestionText(value).split(" ").filter((token) => token.length >= 4))] const getSuggestionTokens = (value) => [...new Set(normalizeSuggestionText(value).split(" ").filter((token) => token.length >= 4))]
@@ -223,7 +300,7 @@ const getAmountMatchLabel = (openSum, remaining) => {
const difference = Math.abs(Math.abs(Number(openSum)) - Math.abs(Number(remaining))) const difference = Math.abs(Math.abs(Number(openSum)) - Math.abs(Number(remaining)))
if (difference < 0.01) return "Betrag exakt" if (difference < 0.01) return "Betrag exakt"
if (difference <= 1) return "Betrag fast passend" if (difference <= 1) return "Betrag fast passend"
if (difference <= 5) return "Betrag aehnlich" if (difference <= 5) return "Betrag ähnlich"
return null return null
} }
@@ -276,7 +353,7 @@ const getTopEntitySuggestion = (statement) => {
reason = "Name passt" reason = "Name passt"
} else if (partialNameMatch) { } else if (partialNameMatch) {
score = 45 score = 45
reason = "Name aehnlich" reason = "Name ähnlich"
} }
return { return {
@@ -401,10 +478,10 @@ const filteredRows = computed(() => {
// Filterung nach Datum // Filterung nach Datum
if (dateRange.value.start) { if (dateRange.value.start) {
temp = temp.filter(i => $dayjs(i.valueDate).isSameOrAfter($dayjs(dateRange.value.start), 'day')) temp = temp.filter(i => $dayjs(getStatementDate(i)).isSameOrAfter($dayjs(dateRange.value.start), 'day'))
} }
if (dateRange.value.end) { if (dateRange.value.end) {
temp = temp.filter(i => $dayjs(i.valueDate).isSameOrBefore($dayjs(dateRange.value.end), 'day')) temp = temp.filter(i => $dayjs(getStatementDate(i)).isSameOrBefore($dayjs(dateRange.value.end), 'day'))
} }
// Status Filter // Status Filter
@@ -419,13 +496,13 @@ const filteredRows = computed(() => {
} }
// Konto Filter & Suche // Konto Filter & Suche
let results = temp.filter(i => filterAccount.value.find(x => x.id === i.account)) let results = temp.filter(i => filterAccount.value.find(x => getBankAccountId(x) === i.account))
if (searchString.value) { if (searchString.value) {
results = useSearch(searchString.value, results) results = useSearch(searchString.value, results)
} }
return results.sort((a, b) => $dayjs(b.valueDate).unix() - $dayjs(a.valueDate).unix()) return results.sort((a, b) => $dayjs(getStatementDate(b)).unix() - $dayjs(getStatementDate(a)).unix())
}) })
const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")}` const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")}`
@@ -492,13 +569,22 @@ onMounted(() => {
<template> <template>
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length"> <UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
<template #right> <template #right>
<UButton
v-if="selectedCashbookAccount"
color="primary"
variant="solid"
icon="i-heroicons-plus"
@click="cashbookBookingModalOpen = true"
>
Eintrag hinzufügen
</UButton>
<UButton <UButton
color="primary" color="primary"
variant="soft" variant="soft"
icon="i-heroicons-sparkles" icon="i-heroicons-sparkles"
@click="suggestionsModalOpen = true" @click="suggestionsModalOpen = true"
> >
Vorschlaege Vorschläge
<UBadge color="primary" variant="solid" size="xs" class="ml-2">{{ suggestionCount }}</UBadge> <UBadge color="primary" variant="solid" size="xs" class="ml-2">{{ suggestionCount }}</UBadge>
</UButton> </UButton>
<UButton <UButton
@@ -525,14 +611,13 @@ onMounted(() => {
:items="bankaccounts" :items="bankaccounts"
v-model="filterAccount" v-model="filterAccount"
value-key="id" value-key="id"
label-key="iban" label-key="displayLabel"
multiple multiple
by="id"
placeholder="Konten" placeholder="Konten"
class="w-48" class="w-64"
> >
<template #default> <template #default>
{{ filterAccount.length > 0 ? `${filterAccount.length} Kont${filterAccount.length > 1 ? 'en' : 'o'}` : 'Konten' }} {{ selectedAccountLabel }}
</template> </template>
</USelectMenu> </USelectMenu>
<USeparator orientation="vertical" class="h-6"/> <USeparator orientation="vertical" class="h-6"/>
@@ -639,7 +724,7 @@ onMounted(() => {
<td colspan="6" class="bg-gray-50 dark:bg-gray-800/50 p-2 pl-4 text-sm font-bold text-primary-600 border-y dark:border-gray-800"> <td colspan="6" class="bg-gray-50 dark:bg-gray-800/50 p-2 pl-4 text-sm font-bold text-primary-600 border-y dark:border-gray-800">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UIcon name="i-heroicons-calendar" class="w-4 h-4"/> <UIcon name="i-heroicons-calendar" class="w-4 h-4"/>
{{ $dayjs(row.valueDate).format('MMMM YYYY') }} {{ $dayjs(getStatementDate(row)).format('MMMM YYYY') }}
</div> </div>
</td> </td>
</tr> </tr>
@@ -648,9 +733,9 @@ onMounted(() => {
@click="router.push(`/banking/statements/edit/${row.id}`)" @click="router.push(`/banking/statements/edit/${row.id}`)"
> >
<td class="p-4 text-[10px] text-gray-400 font-mono truncate max-w-[150px]"> <td class="p-4 text-[10px] text-gray-400 font-mono truncate max-w-[150px]">
{{ row.account ? bankaccounts.find(i => i.id === row.account)?.iban : "" }} {{ row.account ? getBankAccountLabel(bankaccounts.find(i => i.id === row.account)) : "" }}
</td> </td>
<td class="p-4 whitespace-nowrap">{{ $dayjs(row.valueDate).format("DD.MM.YY") }}</td> <td class="p-4 whitespace-nowrap">{{ $dayjs(getStatementDate(row)).format("DD.MM.YY") }}</td>
<td class="p-4 font-semibold"> <td class="p-4 font-semibold">
<span :class="row.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-rose-600 dark:text-rose-400'"> <span :class="row.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-rose-600 dark:text-rose-400'">
{{ displayCurrency(row.amount) }} {{ displayCurrency(row.amount) }}
@@ -680,13 +765,82 @@ onMounted(() => {
</div> </div>
<PageLeaveGuard :when="isSyncing"/> <PageLeaveGuard :when="isSyncing"/>
<UModal v-model:open="cashbookBookingModalOpen" :ui="{ width: 'sm:max-w-lg' }">
<template #content>
<UCard>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-lg font-semibold">Kassenbucheintrag hinzufügen</div>
<div class="text-sm text-gray-500">{{ selectedCashbookAccount?.name || "Kassenbuch" }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<UBadge color="neutral" variant="subtle">
Konto {{ selectedCashbookAccount?.datevNumber || selectedCashbookAccount?.iban }}
</UBadge>
<UBadge :color="currentCashbookBalance < 0 ? 'error' : 'success'" variant="subtle">
Bestand {{ displayCurrency(currentCashbookBalance) }}
</UBadge>
</div>
</div>
</template>
<div class="space-y-4">
<div class="space-y-3">
<UFormField label="Buchungsdatum">
<UInput v-model="cashbookForm.date" type="date" />
</UFormField>
<UFormField label="Betrag">
<UInput v-model="cashbookForm.amount" type="number" min="0" step="0.01" placeholder="0,00" />
</UFormField>
<div class="grid gap-3 sm:grid-cols-2">
<button
type="button"
class="w-full rounded-lg border px-4 py-3 text-left transition"
:class="cashbookForm.direction === 'income'
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30'
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900'"
@click="cashbookForm.direction = 'income'"
>
<div class="font-semibold text-emerald-700 dark:text-emerald-300">Einnahme</div>
<div class="text-sm text-gray-500">Bargeld kommt in die Kasse.</div>
</button>
<button
type="button"
class="w-full rounded-lg border px-4 py-3 text-left transition"
:class="cashbookForm.direction === 'expense'
? 'border-red-500 bg-red-50 dark:bg-red-950/30'
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900'"
@click="cashbookForm.direction = 'expense'"
>
<div class="font-semibold text-red-700 dark:text-red-300">Ausgabe</div>
<div class="text-sm text-gray-500">Bargeld wird aus der Kasse entnommen.</div>
</button>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="cashbookBookingModalOpen = false">
Abbrechen
</UButton>
<UButton color="primary" icon="i-heroicons-check" :loading="savingCashbookBooking" @click="saveCashbookBooking">
Anlegen und bearbeiten
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
<UModal v-model:open="suggestionsModalOpen" :ui="{ width: 'sm:max-w-6xl' }"> <UModal v-model:open="suggestionsModalOpen" :ui="{ width: 'sm:max-w-6xl' }">
<template #content> <template #content>
<UCard> <UCard>
<template #header> <template #header>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div> <div>
<div class="text-lg font-semibold">Vorschlaege fuer Bankbuchungen</div> <div class="text-lg font-semibold">Vorschläge für Bankbuchungen</div>
<div class="text-sm text-gray-500">Direkte Zuweisung von passenden Rechnungen und Eingangsbelegen</div> <div class="text-sm text-gray-500">Direkte Zuweisung von passenden Rechnungen und Eingangsbelegen</div>
</div> </div>
<UBadge color="primary" variant="subtle">{{ suggestionCount }}</UBadge> <UBadge color="primary" variant="subtle">{{ suggestionCount }}</UBadge>
@@ -724,7 +878,7 @@ onMounted(() => {
<div> <div>
<div class="text-sm font-semibold">{{ selectedSuggestionRow.row.amount < 0 ? selectedSuggestionRow.row.credName : selectedSuggestionRow.row.debName }}</div> <div class="text-sm font-semibold">{{ selectedSuggestionRow.row.amount < 0 ? selectedSuggestionRow.row.credName : selectedSuggestionRow.row.debName }}</div>
<div class="text-xs text-gray-500 mt-1">{{ selectedSuggestionRow.row.text || 'Ohne Verwendungszweck' }}</div> <div class="text-xs text-gray-500 mt-1">{{ selectedSuggestionRow.row.text || 'Ohne Verwendungszweck' }}</div>
<div class="text-xs text-gray-400 mt-1">{{ $dayjs(selectedSuggestionRow.row.valueDate).format("DD.MM.YYYY") }}</div> <div class="text-xs text-gray-400 mt-1">{{ $dayjs(getStatementDate(selectedSuggestionRow.row)).format("DD.MM.YYYY") }}</div>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="font-mono font-semibold" :class="selectedSuggestionRow.row.amount >= 0 ? 'text-green-600' : 'text-rose-600'">{{ displayCurrency(selectedSuggestionRow.row.amount) }}</div> <div class="font-mono font-semibold" :class="selectedSuggestionRow.row.amount >= 0 ? 'text-green-600' : 'text-rose-600'">{{ displayCurrency(selectedSuggestionRow.row.amount) }}</div>
@@ -783,7 +937,7 @@ onMounted(() => {
<div v-else class="py-10 text-center text-gray-400"> <div v-else class="py-10 text-center text-gray-400">
<UIcon name="i-heroicons-sparkles" class="w-10 h-10 mx-auto mb-2 opacity-30"/> <UIcon name="i-heroicons-sparkles" class="w-10 h-10 mx-auto mb-2 opacity-30"/>
<p>Keine Vorschlaege fuer die aktuelle Filterung vorhanden.</p> <p>Keine Vorschläge für die aktuelle Filterung vorhanden.</p>
</div> </div>
</UCard> </UCard>
</template> </template>

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