Compare commits
111 Commits
4b85ea3d2d
...
427c0580c4
| Author | SHA1 | Date | |
|---|---|---|---|
| 427c0580c4 | |||
| 47a9af26fe | |||
| 42d2d7dc0e | |||
| 6e0868582a | |||
| 7a6bb4552e | |||
| 0ecdff4d7d | |||
| 0ea4efdc43 | |||
| c854b0bf30 | |||
| a26ff30cd8 | |||
| e9504e21e7 | |||
| 822dcdcfb9 | |||
| 78f9bd3f7a | |||
| 4aea8b94c3 | |||
| 79d620d9c1 | |||
| 8d821a6802 | |||
| 363163f741 | |||
| 384ea95fe5 | |||
| 2a5071b15a | |||
| f2055d59eb | |||
| b91c9d0fd8 | |||
| 29a9e2b63b | |||
| 5264cf54ac | |||
| f002ad867a | |||
| cb09651d8d | |||
| b59599cb92 | |||
| 154d7060f8 | |||
| a34bf43756 | |||
| 2c96b9c5a5 | |||
| d45cefbc20 | |||
| 4fd2eb9c40 | |||
| 8697810127 | |||
| 358cd906ae | |||
| be4a5caaec | |||
| f2adc21fea | |||
| 7239ad92e4 | |||
| 347319aee3 | |||
| 21e2bc2755 | |||
| c699d2ade8 | |||
| 51e0ae95b1 | |||
| 45ca4f7327 | |||
| 38ccdd058b | |||
| 7f47821a7f | |||
| f150cfd740 | |||
| 00da371dfb | |||
| c56fb6b571 | |||
| 0328a4586a | |||
| d73209a150 | |||
| 4bcc2152ab | |||
| ab4055f2a5 | |||
| d6582dd767 | |||
| 3d5bec4ef8 | |||
| 5963a9280c | |||
| cacfce4d15 | |||
| 5400fd7ad5 | |||
| 0d0dc33e84 | |||
| 0e2e4a36be | |||
| 5403418c42 | |||
| ab0f892bc1 | |||
| 69874742f8 | |||
| 736f7bba88 | |||
| ff4328f264 | |||
| 71f5763f7b | |||
| 5a4de421ce | |||
| 19bab852de | |||
| 8a2429827c | |||
| 3594dc69e8 | |||
| 25e0c5389c | |||
| 520052e71a | |||
| da9cad1513 | |||
| b44c8d453a | |||
| bbbdc4d2ae | |||
| b15d98f6e9 | |||
| f36cbcc207 | |||
| 76764eb4c3 | |||
| 0bd0120ec2 | |||
| 266c07d820 | |||
| cc34acac3e | |||
| 31b8378b87 | |||
| 33ff46744f | |||
| c44d8e172d | |||
| bddb326e18 | |||
| 81cecad668 | |||
| 7950315291 | |||
| 9da30ac2e8 | |||
| fb1ccf91b9 | |||
| 42bed16e25 | |||
| 30cbc18b3a | |||
| b6705e84a7 | |||
| d26fe6dcef | |||
| 63bf57e720 | |||
| 1240ffd03b | |||
| 7a893dfdcb | |||
| beb91bf5c3 | |||
| 9bdd725691 | |||
| 821a5f85de | |||
| b667a856d4 | |||
| f6fb607008 | |||
| ee6c2d7420 | |||
| 9e7b5bc0b9 | |||
| ba12c46c88 | |||
| d99cddf5b5 | |||
| df32bf516b | |||
| 151f605eb0 | |||
| 4347a0858d | |||
| 8196f8a955 | |||
| e9bfa3dc1c | |||
| 88006be691 | |||
| fe23742912 | |||
| 6abc0dd772 | |||
| 655a78392b | |||
| 10f03e151d |
53
.env.example
53
.env.example
@@ -37,6 +37,16 @@ S3_ACCESS_KEY=fedeo-minio
|
||||
S3_SECRET_KEY=change-this-minio-password
|
||||
S3_BUCKET=fedeo
|
||||
|
||||
# Datei-Backend. S3 bleibt aktuell der Standard; Seafile kann als externer
|
||||
# Dateidienst angebunden werden, sobald der Backend-Umbau aktiviert ist.
|
||||
FEDEO_FILE_BACKEND=s3
|
||||
|
||||
# Externer Seafile-Dienst, nicht Teil des Standard-Compose-Stacks.
|
||||
SEAFILE_BASE_URL=https://files.example.com
|
||||
SEAFILE_INTERNAL_URL=https://files.example.com
|
||||
SEAFILE_ADMIN_EMAIL=admin@example.com
|
||||
SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
|
||||
|
||||
M2M_API_KEY=change-this-m2m-key
|
||||
API_BASE_URL=https://app.example.com/backend
|
||||
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
||||
@@ -56,6 +66,49 @@ NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
||||
# Interner Prometheus Node Exporter für die Admin-Systemstatusseite.
|
||||
NODE_EXPORTER_URL=http://node-exporter:9100
|
||||
|
||||
# Lokaler Asterisk-Test für SIP/Voice. Aktivieren, wenn das Compose-Profil
|
||||
# `telephony-dev` genutzt wird.
|
||||
TELEPHONY_ENABLED=false
|
||||
ASTERISK_IMAGE=andrius/asterisk:20
|
||||
TELEPHONY_ASTERISK_HTTP_URL=http://asterisk-dev:8088/ws
|
||||
TELEPHONY_ASTERISK_WS_URL=ws://localhost:8088/ws
|
||||
TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated
|
||||
TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev
|
||||
TELEPHONY_ASTERISK_AMI_PORT=5038
|
||||
TELEPHONY_ASTERISK_AMI_USER=fedeo
|
||||
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
|
||||
TELEPHONY_SIP_DOMAIN=localhost
|
||||
TELEPHONY_TEST_EXTENSION=1001
|
||||
TELEPHONY_TEST_PASSWORD=fedeo-test-1001
|
||||
TELEPHONY_TEST_EXTENSION_2=1002
|
||||
TELEPHONY_TEST_PASSWORD_2=fedeo-test-1002
|
||||
TELEPHONY_ECHO_EXTENSION=600
|
||||
TELEPHONY_DEV_WS_PORT=8088
|
||||
TELEPHONY_DEV_AMI_PORT=5038
|
||||
TELEPHONY_DEV_SIP_PORT=5060
|
||||
TELEPHONY_DEV_RTP_MIN_PORT=10000
|
||||
TELEPHONY_DEV_RTP_MAX_PORT=10100
|
||||
TELEPHONY_ASTERISK_EXTERNAL_SIGNALING_ADDRESS=
|
||||
TELEPHONY_ASTERISK_EXTERNAL_MEDIA_ADDRESS=
|
||||
|
||||
# Externe Telefonie über Telekom/tel.t-online.de. Keine echten Zugangsdaten
|
||||
# einchecken. SIP-ID ist in der Regel die Rufnummer mit Vorwahl ohne Leerzeichen
|
||||
# und ohne Sonderzeichen, z. B. 0301234567. Wenn dein Anschluss noch die
|
||||
# Internet-Zugangsdaten als Auth-User nutzt, kann TELEPHONY_TELEKOM_AUTH_USER
|
||||
# aus Anschlusskennung + Zugangsnummer + # + Mitbenutzernummer + @t-online.de
|
||||
# gebildet werden.
|
||||
TELEPHONY_EXTERNAL_PROVIDER=
|
||||
TELEPHONY_EXTERNAL_ENABLED=false
|
||||
TELEPHONY_EXTERNAL_INBOUND_EXTENSION=1001
|
||||
TELEPHONY_TELEKOM_ENABLED=false
|
||||
TELEPHONY_TELEKOM_REGISTRAR=tel.t-online.de
|
||||
TELEPHONY_TELEKOM_SIP_USER=
|
||||
TELEPHONY_TELEKOM_AUTH_USER=
|
||||
TELEPHONY_TELEKOM_PASSWORD=
|
||||
TELEPHONY_TELEKOM_CALLER_ID=
|
||||
TELEPHONY_TELEKOM_INBOUND_EXTENSION=1001
|
||||
TELEPHONY_TELEKOM_OUTBOUND_PREFIX=0
|
||||
|
||||
# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
|
||||
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
|
||||
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
10
README.md
10
README.md
@@ -132,7 +132,7 @@ Als Startpunkt kannst du die Beispielumgebung kopieren:
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Ersetze anschließend alle Platzhalter und passe mindestens `DOMAIN`, `CONTACT_EMAIL`, Datenbank-, Secret-, SMTP- und S3-Werte an.
|
||||
Ersetze anschließend alle Platzhalter und passe mindestens `DOMAIN`, `CONTACT_EMAIL`, Datenbank-, Secret-, SMTP- und S3-Werte an. Seafile ist kein Teil des Standard-Stacks; wenn FEDEO später Seafile als File-Backend nutzen soll, zeigst du die Seafile-Variablen auf einen externen Seafile-Dienst.
|
||||
|
||||
Alternativ kannst du die Konfiguration geführt erzeugen lassen:
|
||||
|
||||
@@ -204,6 +204,12 @@ S3_ACCESS_KEY=fedeo-minio
|
||||
S3_SECRET_KEY=change-this-minio-password
|
||||
S3_BUCKET=fedeo
|
||||
|
||||
FEDEO_FILE_BACKEND=s3
|
||||
SEAFILE_BASE_URL=https://files.example.com
|
||||
SEAFILE_INTERNAL_URL=https://files.example.com
|
||||
SEAFILE_ADMIN_EMAIL=admin@example.com
|
||||
SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
|
||||
|
||||
M2M_API_KEY=change-this-m2m-key
|
||||
API_BASE_URL=https://app.example.com/backend
|
||||
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
||||
@@ -250,6 +256,8 @@ Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BO
|
||||
|
||||
Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
||||
|
||||
Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist.
|
||||
|
||||
Der Matrix-Stack ist im Selfhost-Compose direkt enthalten. Er umfasst Synapse, eine eigene PostgreSQL-Datenbank für Synapse, Redis, `.well-known/matrix`, coturn, LiveKit, den LiveKit-JWT-Service und Element Web. Das einfache Selfhost-Setup nutzt nur `DOMAIN`: Synapse läuft unter `https://DOMAIN/_matrix`, Matrix-Well-Known unter `https://DOMAIN/.well-known/matrix`, LiveKit unter `https://DOMAIN/livekit/sfu`, der JWT-Service unter `https://DOMAIN/livekit/jwt` und Element Web unter `https://DOMAIN/element`.
|
||||
|
||||
Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest.
|
||||
|
||||
6
agents/fedeo-device-agent/.dockerignore
Normal file
6
agents/fedeo-device-agent/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.venv-opencv
|
||||
.env
|
||||
*.log
|
||||
*.tmp
|
||||
14
agents/fedeo-device-agent/.env.example
Normal file
14
agents/fedeo-device-agent/.env.example
Normal 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
6
agents/fedeo-device-agent/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
dist
|
||||
node_modules
|
||||
.venv-opencv
|
||||
.env
|
||||
*.log
|
||||
*.tmp
|
||||
45
agents/fedeo-device-agent/Dockerfile
Normal file
45
agents/fedeo-device-agent/Dockerfile
Normal 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"]
|
||||
133
agents/fedeo-device-agent/README.md
Normal file
133
agents/fedeo-device-agent/README.md
Normal 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`.
|
||||
28
agents/fedeo-device-agent/docker-compose.example.yml
Normal file
28
agents/fedeo-device-agent/docker-compose.example.yml
Normal 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:
|
||||
26
agents/fedeo-device-agent/package.json
Normal file
26
agents/fedeo-device-agent/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
agents/fedeo-device-agent/requirements-opencv.txt
Normal file
3
agents/fedeo-device-agent/requirements-opencv.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
opencv-python-headless>=4.9
|
||||
Pillow>=10.0
|
||||
numpy>=1.26
|
||||
219
agents/fedeo-device-agent/scripts/opencv_postprocess.py
Normal file
219
agents/fedeo-device-agent/scripts/opencv_postprocess.py
Normal 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()
|
||||
26
agents/fedeo-device-agent/scripts/setup-opencv.sh
Normal file
26
agents/fedeo-device-agent/scripts/setup-opencv.sh
Normal 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"
|
||||
67
agents/fedeo-device-agent/src/api.ts
Normal file
67
agents/fedeo-device-agent/src/api.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
48
agents/fedeo-device-agent/src/commands.ts
Normal file
48
agents/fedeo-device-agent/src/commands.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
68
agents/fedeo-device-agent/src/config.ts
Normal file
68
agents/fedeo-device-agent/src/config.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
32
agents/fedeo-device-agent/src/env.ts
Normal file
32
agents/fedeo-device-agent/src/env.ts
Normal 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
|
||||
}
|
||||
}
|
||||
30
agents/fedeo-device-agent/src/logger.ts
Normal file
30
agents/fedeo-device-agent/src/logger.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
93
agents/fedeo-device-agent/src/main.ts
Normal file
93
agents/fedeo-device-agent/src/main.ts
Normal 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)
|
||||
})
|
||||
15
agents/fedeo-device-agent/src/print/cups.ts
Normal file
15
agents/fedeo-device-agent/src/print/cups.ts
Normal 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))
|
||||
}
|
||||
66
agents/fedeo-device-agent/src/scan/postprocess.ts
Normal file
66
agents/fedeo-device-agent/src/scan/postprocess.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
149
agents/fedeo-device-agent/src/scan/sane.ts
Normal file
149
agents/fedeo-device-agent/src/scan/sane.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
48
agents/fedeo-device-agent/src/types.ts
Normal file
48
agents/fedeo-device-agent/src/types.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
17
agents/fedeo-device-agent/tsconfig.json
Normal file
17
agents/fedeo-device-agent/tsconfig.json
Normal 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"]
|
||||
}
|
||||
57
backend/db/migrations/0044_telephony_calls.sql
Normal file
57
backend/db/migrations/0044_telephony_calls.sql
Normal 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");
|
||||
50
backend/db/migrations/0045_telephony_trunks.sql
Normal file
50
backend/db/migrations/0045_telephony_trunks.sql
Normal 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");
|
||||
3
backend/db/migrations/0046_telephony_trunk_nat.sql
Normal file
3
backend/db/migrations/0046_telephony_trunk_nat.sql
Normal 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;
|
||||
92
backend/db/migrations/0047_telephony_extensions.sql
Normal file
92
backend/db/migrations/0047_telephony_extensions.sql
Normal 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");
|
||||
19
backend/db/migrations/0048_mobile_push_devices.sql
Normal file
19
backend/db/migrations/0048_mobile_push_devices.sql
Normal 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");
|
||||
106
backend/db/migrations/0049_email_cache.sql
Normal file
106
backend/db/migrations/0049_email_cache.sql
Normal 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");
|
||||
@@ -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;
|
||||
43
backend/db/migrations/0051_instance_scan_agents.sql
Normal file
43
backend/db/migrations/0051_instance_scan_agents.sql
Normal 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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -309,6 +309,55 @@
|
||||
"when": 1780156800000,
|
||||
"tag": "0043_communication_rooms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "7",
|
||||
"when": 1780160400000,
|
||||
"tag": "0044_telephony_calls",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "7",
|
||||
"when": 1780164000000,
|
||||
"tag": "0045_telephony_trunks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "7",
|
||||
"when": 1780167600000,
|
||||
"tag": "0046_telephony_trunk_nat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "7",
|
||||
"when": 1780171200000,
|
||||
"tag": "0047_telephony_extensions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "7",
|
||||
"when": 1780174800000,
|
||||
"tag": "0048_mobile_push_devices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 49,
|
||||
"version": "7",
|
||||
"when": 1780178400000,
|
||||
"tag": "0049_email_cache",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 50,
|
||||
"version": "7",
|
||||
"when": 1780261200000,
|
||||
"tag": "0050_outgoing_document_costcentres",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { plants } from "./plants"
|
||||
import { authUsers } from "./auth_users"
|
||||
import {serialExecutions} from "./serialexecutions";
|
||||
import { outgoingsepamandates } from "./outgoingsepamandates"
|
||||
import { costcentres } from "./costcentres"
|
||||
|
||||
export const createddocuments = pgTable("createddocuments", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -49,6 +50,8 @@ export const createddocuments = pgTable("createddocuments", {
|
||||
() => projects.id
|
||||
),
|
||||
|
||||
costcentre: uuid("costcentre").references(() => costcentres.id),
|
||||
|
||||
documentNumber: text("documentNumber"),
|
||||
documentDate: text("documentDate"),
|
||||
|
||||
|
||||
208
backend/db/schema/emails.ts
Normal file
208
backend/db/schema/emails.ts
Normal 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
|
||||
@@ -27,6 +27,7 @@ export * from "./customerspaces"
|
||||
export * from "./customerinventoryitems"
|
||||
export * from "./devices"
|
||||
export * from "./documentboxes"
|
||||
export * from "./emails"
|
||||
export * from "./enums"
|
||||
export * from "./events"
|
||||
export * from "./entitybankaccounts"
|
||||
@@ -47,6 +48,8 @@ export * from "./historyitems"
|
||||
export * from "./holidays"
|
||||
export * from "./hourrates"
|
||||
export * from "./incominginvoices"
|
||||
export * from "./instance_agents"
|
||||
export * from "./instance_agent_scan_jobs"
|
||||
export * from "./inventoryitemgroups"
|
||||
export * from "./inventoryitems"
|
||||
export * from "./letterheads"
|
||||
@@ -55,6 +58,7 @@ export * from "./movements"
|
||||
export * from "./m2m_api_keys"
|
||||
export * from "./notifications_event_types"
|
||||
export * from "./notifications_items"
|
||||
export * from "./notification_mobile_push_devices"
|
||||
export * from "./notifications_preferences"
|
||||
export * from "./notifications_preferences_defaults"
|
||||
export * from "./notification_push_subscriptions"
|
||||
@@ -75,6 +79,9 @@ export * from "./statementallocations"
|
||||
export * from "./tasks"
|
||||
export * from "./teams"
|
||||
export * from "./taxtypes"
|
||||
export * from "./telephony_calls"
|
||||
export * from "./telephony_extensions"
|
||||
export * from "./telephony_trunks"
|
||||
export * from "./tenants"
|
||||
export * from "./texttemplates"
|
||||
export * from "./units"
|
||||
|
||||
59
backend/db/schema/instance_agent_scan_jobs.ts
Normal file
59
backend/db/schema/instance_agent_scan_jobs.ts
Normal 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
|
||||
47
backend/db/schema/instance_agents.ts
Normal file
47
backend/db/schema/instance_agents.ts
Normal 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
|
||||
53
backend/db/schema/notification_mobile_push_devices.ts
Normal file
53
backend/db/schema/notification_mobile_push_devices.ts
Normal 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
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { tenants } from "./tenants"
|
||||
import { units } from "./units"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { costcentres } from "./costcentres"
|
||||
|
||||
export const services = pgTable("services", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -35,6 +36,8 @@ export const services = pgTable("services", {
|
||||
|
||||
unit: bigint("unit", { mode: "number" }).references(() => units.id),
|
||||
|
||||
costcentre: uuid("costcentre").references(() => costcentres.id),
|
||||
|
||||
serviceNumber: bigint("serviceNumber", { mode: "number" }),
|
||||
|
||||
tags: jsonb("tags").default([]),
|
||||
|
||||
56
backend/db/schema/telephony_calls.ts
Normal file
56
backend/db/schema/telephony_calls.ts
Normal 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
|
||||
53
backend/db/schema/telephony_extensions.ts
Normal file
53
backend/db/schema/telephony_extensions.ts
Normal 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
|
||||
53
backend/db/schema/telephony_trunks.ts
Normal file
53
backend/db/schema/telephony_trunks.ts
Normal 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
|
||||
@@ -32,6 +32,9 @@ import wikiRoutes from "./routes/wiki";
|
||||
import portalContractRoutes from "./routes/portal/contracts";
|
||||
import mcpRoutes from "./routes/mcp";
|
||||
import communicationRoutes from "./routes/communication";
|
||||
import telephonyRoutes from "./routes/telephony";
|
||||
import instanceAgentRoutes from "./routes/instanceAgents";
|
||||
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
|
||||
|
||||
//Public Links
|
||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||
@@ -57,6 +60,7 @@ import {loadSecrets, secrets} from "./utils/secrets";
|
||||
import {initMailer} from "./utils/mailer"
|
||||
import {initS3} from "./utils/s3";
|
||||
import { runBootstrap } from "./modules/bootstrap.service";
|
||||
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
|
||||
|
||||
|
||||
//Services
|
||||
@@ -82,6 +86,7 @@ async function main() {
|
||||
await app.register(dbPlugin);
|
||||
await app.register(servicesPlugin);
|
||||
await runBootstrap(app);
|
||||
startMatrixPushWorker(app);
|
||||
|
||||
app.addHook('preHandler', (req, reply, done) => {
|
||||
console.log(req.method)
|
||||
@@ -125,6 +130,10 @@ async function main() {
|
||||
await devicesApp.register(devicesManagementRoutes)
|
||||
},{prefix: "/devices"})
|
||||
|
||||
await app.register(async (agentApp) => {
|
||||
await agentApp.register(instanceAgentGatewayRoutes)
|
||||
},{prefix: "/instance-agent"})
|
||||
|
||||
await app.register(corsPlugin);
|
||||
|
||||
//Geschützte Routes
|
||||
@@ -154,6 +163,8 @@ async function main() {
|
||||
await subApp.register(portalContractRoutes);
|
||||
await subApp.register(mcpRoutes);
|
||||
await subApp.register(communicationRoutes);
|
||||
await subApp.register(telephonyRoutes);
|
||||
await subApp.register(instanceAgentRoutes);
|
||||
|
||||
},{prefix: "/api"})
|
||||
|
||||
|
||||
@@ -205,6 +205,8 @@ const buildOutgoingDocumentPayload = (
|
||||
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
||||
}
|
||||
|
||||
if (args.costcentre !== undefined) payload.costcentre = stringArg(args, "costcentre")
|
||||
|
||||
for (const field of ["paymentDays"] as const) {
|
||||
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
||||
}
|
||||
@@ -458,6 +460,7 @@ export const accountingTools: McpTool[] = [
|
||||
contact: { type: "number" },
|
||||
contract: { type: "number" },
|
||||
project: { type: "number" },
|
||||
costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." },
|
||||
plant: { type: "number" },
|
||||
documentDate: { type: "string" },
|
||||
deliveryDate: { type: "string" },
|
||||
@@ -512,6 +515,7 @@ export const accountingTools: McpTool[] = [
|
||||
contact: { type: "number" },
|
||||
contract: { type: "number" },
|
||||
project: { type: "number" },
|
||||
costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." },
|
||||
plant: { type: "number" },
|
||||
documentDate: { type: "string" },
|
||||
deliveryDate: { type: "string" },
|
||||
|
||||
864
backend/src/modules/email/email.sync.service.ts
Normal file
864
backend/src/modules/email/email.sync.service.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
377
backend/src/modules/matrix-push-worker.service.ts
Normal file
377
backend/src/modules/matrix-push-worker.service.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { existsSync, readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { authProfiles, authTenantUsers, authUsers, communicationRooms, tenants } from "../../db/schema"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { and, eq, isNotNull } from "drizzle-orm"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import jwt from "jsonwebtoken"
|
||||
|
||||
type MatrixErrorResponse = {
|
||||
errcode?: string
|
||||
error?: string
|
||||
retry_after_ms?: number
|
||||
}
|
||||
|
||||
type MatrixRoomEvent = {
|
||||
@@ -382,13 +383,23 @@ export function matrixService(server: FastifyInstance) {
|
||||
const password = serviceUserPassword()
|
||||
|
||||
try {
|
||||
await registerWithSharedSecret(username, password, true)
|
||||
} catch (err: any) {
|
||||
if (err.errcode !== "M_USER_IN_USE") {
|
||||
throw err
|
||||
const login = await loginMatrixUser(username, password)
|
||||
matrixServiceSessionCache = {
|
||||
accessToken: login.access_token,
|
||||
matrixUserId: login.user_id,
|
||||
validUntilMs: Date.now() + 30 * 60 * 1000,
|
||||
}
|
||||
|
||||
return matrixServiceSessionCache
|
||||
} catch (loginErr: any) {
|
||||
if (loginErr.statusCode === 429) throw loginErr
|
||||
}
|
||||
|
||||
try {
|
||||
await registerWithSharedSecret(username, password, true)
|
||||
} catch (registerErr: any) {
|
||||
if (registerErr.errcode !== "M_USER_IN_USE") throw registerErr
|
||||
}
|
||||
const login = await loginMatrixUser(username, password)
|
||||
matrixServiceSessionCache = {
|
||||
accessToken: login.access_token,
|
||||
@@ -442,6 +453,7 @@ export function matrixService(server: FastifyInstance) {
|
||||
{
|
||||
statusCode: response.status,
|
||||
errcode: error.errcode,
|
||||
retryAfterMs: error.retry_after_ms,
|
||||
body,
|
||||
}
|
||||
)
|
||||
@@ -1126,6 +1138,86 @@ export function matrixService(server: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const ensureServiceUserJoinedRoom = async (room: { roomId?: string | null; alias?: string | null }) => {
|
||||
const target = room.roomId || room.alias
|
||||
if (!target) return { ok: false, status: "missing_room" }
|
||||
|
||||
const serviceLogin = await ensureServiceAccessToken()
|
||||
|
||||
try {
|
||||
await requestMatrixJson(
|
||||
`/_synapse/admin/v1/join/${encodeURIComponent(target)}`,
|
||||
serviceLogin.accessToken,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: serviceLogin.matrixUserId }),
|
||||
}
|
||||
)
|
||||
|
||||
return { ok: true, status: "joined_admin", roomId: target }
|
||||
} catch (adminErr: any) {
|
||||
try {
|
||||
await requestMatrixJson(
|
||||
`/_matrix/client/v3/join/${encodeURIComponent(target)}`,
|
||||
serviceLogin.accessToken,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
|
||||
return { ok: true, status: "joined_client", roomId: target }
|
||||
} catch (clientErr: any) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "failed",
|
||||
roomId: target,
|
||||
error: clientErr.message || adminErr.message || "Service-Join fehlgeschlagen",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncServiceJoinedTenantRooms = async () => {
|
||||
const rooms = await server.db
|
||||
.select({
|
||||
id: communicationRooms.id,
|
||||
tenantId: communicationRooms.tenantId,
|
||||
key: communicationRooms.key,
|
||||
name: communicationRooms.name,
|
||||
matrixRoomId: communicationRooms.matrixRoomId,
|
||||
matrixAlias: communicationRooms.matrixAlias,
|
||||
})
|
||||
.from(communicationRooms)
|
||||
.where(and(
|
||||
eq(communicationRooms.archived, false),
|
||||
isNotNull(communicationRooms.matrixRoomId)
|
||||
))
|
||||
|
||||
const results = []
|
||||
for (const room of rooms) {
|
||||
const result = await ensureServiceUserJoinedRoom({
|
||||
roomId: room.matrixRoomId,
|
||||
alias: room.matrixAlias,
|
||||
})
|
||||
results.push({
|
||||
roomId: room.matrixRoomId,
|
||||
roomKey: room.key,
|
||||
roomName: room.name,
|
||||
...result,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
total: results.length,
|
||||
joined: results.filter((result) => result.ok).length,
|
||||
failed: results.filter((result) => !result.ok).length,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
const ensureCurrentUserJoinedRoom = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
@@ -1512,6 +1604,69 @@ export function matrixService(server: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const syncServiceRoomEvents = async (since?: string, initial = false) => {
|
||||
const service = await ensureServiceAccessToken()
|
||||
const filter = {
|
||||
room: {
|
||||
timeline: {
|
||||
limit: 50,
|
||||
},
|
||||
},
|
||||
presence: {
|
||||
types: [],
|
||||
},
|
||||
account_data: {
|
||||
types: [],
|
||||
},
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
timeout: since && !initial ? "25000" : "0",
|
||||
filter: JSON.stringify(filter),
|
||||
})
|
||||
|
||||
if (since) params.set("since", since)
|
||||
|
||||
const response = await requestMatrixJson<MatrixSyncResponse>(
|
||||
`/_matrix/client/v3/sync?${params.toString()}`,
|
||||
service.accessToken
|
||||
)
|
||||
const joinedRooms = response.rooms?.join || {}
|
||||
|
||||
return {
|
||||
nextBatch: response.next_batch || since || "",
|
||||
serviceUserId: service.matrixUserId,
|
||||
rooms: Object.entries(joinedRooms).map(([roomId, joinedRoom]) => {
|
||||
const timelineEvents = joinedRoom.timeline?.events || []
|
||||
const messages = initial
|
||||
? []
|
||||
: timelineEvents
|
||||
.filter((event) =>
|
||||
event.type === "m.room.message" &&
|
||||
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") &&
|
||||
event.content?.["m.relates_to"]?.rel_type !== "m.replace"
|
||||
)
|
||||
.map((event) => ({
|
||||
id: event.event_id,
|
||||
roomId,
|
||||
sender: event.sender,
|
||||
senderDisplayName: event.sender,
|
||||
body: event.content?.body || "",
|
||||
attachment: attachmentFromEvent(event),
|
||||
timestamp: event.origin_server_ts,
|
||||
own: event.sender === service.matrixUserId,
|
||||
replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null,
|
||||
}))
|
||||
|
||||
return {
|
||||
roomId,
|
||||
messages,
|
||||
membersChanged: [...timelineEvents, ...(joinedRoom.state?.events || [])]
|
||||
.some((event) => event.type === "m.room.member"),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const sendTenantRoomMessage = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
@@ -2189,6 +2344,8 @@ export function matrixService(server: FastifyInstance) {
|
||||
getTenantRoomMembers,
|
||||
searchTenantRoomMessages,
|
||||
syncTenantRoomEvents,
|
||||
syncServiceRoomEvents,
|
||||
syncServiceJoinedTenantRooms,
|
||||
sendTenantRoomMessage,
|
||||
sendTenantRoomReaction,
|
||||
editTenantRoomMessage,
|
||||
|
||||
@@ -3,6 +3,7 @@ import webPush from "web-push"
|
||||
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||
import {
|
||||
authUsers,
|
||||
notificationMobilePushDevices,
|
||||
notificationPushSubscriptions,
|
||||
notificationsEventTypes,
|
||||
notificationsItems,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
notificationsPreferencesDefaults,
|
||||
} from "../../db/schema"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { pushServerClient } from "./push-server.client"
|
||||
|
||||
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
|
||||
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
|
||||
@@ -280,18 +282,8 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
private async deliverPush(item: typeof notificationsItems.$inferSelect) {
|
||||
if (!secrets.WEB_PUSH_PUBLIC_KEY || !secrets.WEB_PUSH_PRIVATE_KEY) {
|
||||
await this.markFailed(item.id, "Web Push ist nicht konfiguriert")
|
||||
return { success: false, id: item.id, channel: item.channel }
|
||||
}
|
||||
|
||||
webPush.setVapidDetails(
|
||||
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
|
||||
secrets.WEB_PUSH_PUBLIC_KEY,
|
||||
secrets.WEB_PUSH_PRIVATE_KEY
|
||||
)
|
||||
|
||||
const subscriptions = await this.server.db
|
||||
const subscriptions = secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY
|
||||
? await this.server.db
|
||||
.select()
|
||||
.from(notificationPushSubscriptions)
|
||||
.where(and(
|
||||
@@ -299,8 +291,18 @@ export class NotificationService {
|
||||
eq(notificationPushSubscriptions.userId, item.userId),
|
||||
isNull(notificationPushSubscriptions.disabledAt)
|
||||
))
|
||||
: []
|
||||
|
||||
if (!subscriptions.length) {
|
||||
const mobileDevices = await this.server.db
|
||||
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
|
||||
.from(notificationMobilePushDevices)
|
||||
.where(and(
|
||||
eq(notificationMobilePushDevices.tenantId, item.tenantId),
|
||||
eq(notificationMobilePushDevices.userId, item.userId),
|
||||
isNull(notificationMobilePushDevices.disabledAt)
|
||||
))
|
||||
|
||||
if (!subscriptions.length && !mobileDevices.length) {
|
||||
await this.markFailed(item.id, "Keine aktive Push-Subscription")
|
||||
return { success: false, id: item.id, channel: item.channel }
|
||||
}
|
||||
@@ -315,6 +317,37 @@ export class NotificationService {
|
||||
let delivered = 0
|
||||
const errors: string[] = []
|
||||
|
||||
if (mobileDevices.length) {
|
||||
try {
|
||||
const result = await pushServerClient.sendPush({
|
||||
idempotencyKey: `notification:${item.id}`,
|
||||
devices: mobileDevices.map((device) => device.centralDeviceId),
|
||||
priority: "high",
|
||||
ttlSeconds: 3600,
|
||||
notification: {
|
||||
title: item.title,
|
||||
body: item.message,
|
||||
},
|
||||
data: {
|
||||
notificationId: item.id,
|
||||
...(typeof item.payload === "object" && item.payload !== null ? item.payload as Record<string, unknown> : {}),
|
||||
},
|
||||
})
|
||||
delivered += result.accepted
|
||||
if (result.rejected) errors.push(`${result.rejected} mobile Geräte vom Push-Server abgelehnt`)
|
||||
} catch (error: any) {
|
||||
errors.push(error?.message || String(error))
|
||||
}
|
||||
}
|
||||
|
||||
if (subscriptions.length) {
|
||||
webPush.setVapidDetails(
|
||||
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
|
||||
secrets.WEB_PUSH_PUBLIC_KEY!,
|
||||
secrets.WEB_PUSH_PRIVATE_KEY!
|
||||
)
|
||||
}
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
try {
|
||||
await webPush.sendNotification({
|
||||
|
||||
101
backend/src/modules/push-server.client.ts
Normal file
101
backend/src/modules/push-server.client.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -378,6 +378,7 @@ async function getSaveData(item: any, tenant: any, firstDate: string, lastDate:
|
||||
contract: item.contract,
|
||||
address: item.address,
|
||||
project: item.project,
|
||||
costcentre: item.costcentre,
|
||||
documentDate: executionDate,
|
||||
deliveryDate: firstDate,
|
||||
deliveryDateEnd: lastDate,
|
||||
|
||||
@@ -64,6 +64,19 @@ export default fp(async (server: FastifyInstance) => {
|
||||
}
|
||||
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return
|
||||
}
|
||||
|
||||
const urlPath = req.url.split("?")[0]
|
||||
const queryToken = (req.query as any)?.downloadToken
|
||||
const downloadToken =
|
||||
typeof queryToken === "string"
|
||||
&& urlPath.startsWith("/api/email/attachments/")
|
||||
&& urlPath.endsWith("/download")
|
||||
? queryToken
|
||||
: null
|
||||
|
||||
// 1️⃣ Token aus Header oder Cookie lesen
|
||||
const cookieToken = req.cookies?.token
|
||||
const authHeader = req.headers.authorization
|
||||
@@ -74,7 +87,7 @@ export default fp(async (server: FastifyInstance) => {
|
||||
const token =
|
||||
headerToken && headerToken.length > 10
|
||||
? headerToken
|
||||
: cookieToken || null
|
||||
: cookieToken || downloadToken || null
|
||||
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: "Authentication required" })
|
||||
|
||||
@@ -52,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
calendarConfig: tenants.calendarConfig,
|
||||
hasActiveLicense: tenants.hasActiveLicense,
|
||||
locked: tenants.locked,
|
||||
features: tenants.features,
|
||||
|
||||
@@ -260,7 +260,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
}
|
||||
|
||||
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
|
||||
if (!body.date || !dayjs(body.date).isValid()) return reply.code(400).send({ error: "Bitte ein gültiges Buchungsdatum angeben." })
|
||||
const bookingDate = body.date && dayjs(body.date).isValid() ? dayjs(body.date) : dayjs()
|
||||
if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." })
|
||||
if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." })
|
||||
|
||||
@@ -273,8 +273,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
|
||||
if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
|
||||
|
||||
const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
|
||||
if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." })
|
||||
const hasCounterInput = Boolean(body.counterType || body.counterId)
|
||||
const counterPayload = hasCounterInput
|
||||
? buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
|
||||
: null
|
||||
if (hasCounterInput && !counterPayload) return reply.code(400).send({ error: "Bitte ein gültiges Gegenkonto auswählen." })
|
||||
|
||||
const signedAmount = body.direction === "income"
|
||||
? Math.abs(Number(body.amount))
|
||||
@@ -284,7 +287,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const created = await server.db.transaction(async (tx) => {
|
||||
const insertedStatements = await tx.insert(bankstatements).values({
|
||||
account: cashbookId,
|
||||
date: dayjs(body.date).format("YYYY-MM-DD"),
|
||||
date: bookingDate.format("YYYY-MM-DD"),
|
||||
valueDate: bookingDate.format("YYYY-MM-DD"),
|
||||
amount: signedAmount,
|
||||
tenant: req.user.tenant_id,
|
||||
text: description,
|
||||
@@ -295,18 +299,20 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
}).returning()
|
||||
|
||||
const statement = insertedStatements[0]
|
||||
const insertedAllocations = await tx.insert(statementallocations).values({
|
||||
bankstatement: statement.id,
|
||||
amount: signedAmount,
|
||||
tenant: req.user.tenant_id,
|
||||
description,
|
||||
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
|
||||
...counterPayload,
|
||||
}).returning()
|
||||
const insertedAllocations = counterPayload
|
||||
? await tx.insert(statementallocations).values({
|
||||
bankstatement: statement.id,
|
||||
amount: signedAmount,
|
||||
tenant: req.user.tenant_id,
|
||||
description,
|
||||
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
|
||||
...counterPayload,
|
||||
}).returning()
|
||||
: []
|
||||
|
||||
return {
|
||||
statement,
|
||||
allocation: insertedAllocations[0],
|
||||
allocation: insertedAllocations[0] || null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -687,7 +693,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
|
||||
if (matchesBankAccountId && matchesIban) {
|
||||
score = 100
|
||||
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
|
||||
reason = "IBAN und hinterlegte Bankverbindung stimmen überein"
|
||||
} else if (matchesBankAccountId) {
|
||||
score = 95
|
||||
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
||||
@@ -699,7 +705,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
reason = "Name passt exakt zur Buchung"
|
||||
} else if (partialNameMatch) {
|
||||
score = 45
|
||||
reason = "Name aehnelt der Buchung"
|
||||
reason = "Name ähnelt der Buchung"
|
||||
}
|
||||
|
||||
if (!score) continue
|
||||
@@ -743,7 +749,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
|
||||
if (matchesBankAccountId && matchesIban) {
|
||||
score = 100
|
||||
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
|
||||
reason = "IBAN und hinterlegte Bankverbindung stimmen überein"
|
||||
} else if (matchesBankAccountId) {
|
||||
score = 95
|
||||
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
||||
@@ -755,7 +761,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
reason = "Name passt exakt zur Buchung"
|
||||
} else if (partialNameMatch) {
|
||||
score = 45
|
||||
reason = "Name aehnelt der Buchung"
|
||||
reason = "Name ähnelt der Buchung"
|
||||
}
|
||||
|
||||
if (!score) continue
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { FastifyInstance } from "fastify"
|
||||
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 { matrixService } from "../modules/matrix.service"
|
||||
import { getMatrixPushWorkerState } from "../modules/matrix-push-worker.service"
|
||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||
|
||||
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||
@@ -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 [sender] = await server.db
|
||||
.select({
|
||||
@@ -150,6 +175,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
|
||||
if (room?.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
|
||||
directRecipients.add(room.entityUuid)
|
||||
} else if (room?.type === "direct" && room.key) {
|
||||
recipients
|
||||
.filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key)
|
||||
.forEach((recipient) => directRecipients.add(recipient.userId))
|
||||
}
|
||||
|
||||
return recipients
|
||||
@@ -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) => {
|
||||
return await server.db
|
||||
.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) => {
|
||||
try {
|
||||
const query = req.query as { uri?: string; name?: string }
|
||||
@@ -739,13 +833,19 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
server.get("/communication/matrix/rooms/:roomKey/sync", async (req, reply) => {
|
||||
try {
|
||||
const query = req.query as { since?: string; initial?: string }
|
||||
return await matrix.syncTenantRoomEvents(
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1,17 +1,72 @@
|
||||
import nodemailer from "nodemailer"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { sendMailAsUser } from "../utils/emailengine"
|
||||
import { encrypt, decrypt } from "../utils/crypt"
|
||||
import { userCredentials } from "../../db/schema"
|
||||
// Pfad ggf. anpassen
|
||||
import { emailSyncService } from "../modules/email/email.sync.service"
|
||||
|
||||
// @ts-ignore
|
||||
import MailComposer from "nodemailer/lib/mail-composer/index.js"
|
||||
import { ImapFlow } from "imapflow"
|
||||
|
||||
export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
const emailSync = emailSyncService(server)
|
||||
|
||||
const encryptedValue = (value: unknown) => value ? decrypt(value as any) : null
|
||||
|
||||
const accountResponse = (row: any) => ({
|
||||
id: row.id,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
userId: row.userId,
|
||||
tenantId: row.tenantId,
|
||||
type: row.type,
|
||||
email: encryptedValue(row.emailEncrypted),
|
||||
smtpHost: encryptedValue(row.smtpHostEncrypted),
|
||||
smtpPort: row.smtpPort ? Number(row.smtpPort) : null,
|
||||
smtpSsl: row.smtpSsl,
|
||||
imapHost: encryptedValue(row.imapHostEncrypted),
|
||||
imapPort: row.imapPort ? Number(row.imapPort) : null,
|
||||
imapSsl: row.imapSsl,
|
||||
hasPassword: Boolean(row.passwordEncrypted),
|
||||
})
|
||||
|
||||
const accountCredentials = (row: any) => ({
|
||||
...accountResponse(row),
|
||||
password: encryptedValue(row.passwordEncrypted),
|
||||
})
|
||||
|
||||
const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey]
|
||||
|
||||
const applyDownloadCorsHeaders = (req: any, reply: any) => {
|
||||
const origin = req.headers.origin
|
||||
if (
|
||||
origin
|
||||
&& (
|
||||
/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin)
|
||||
|| origin === "https://beta.fedeo.de"
|
||||
|| origin === "https://app.fedeo.de"
|
||||
|| origin === "capacitor://localhost"
|
||||
)
|
||||
) {
|
||||
reply.header("Access-Control-Allow-Origin", origin)
|
||||
reply.header("Access-Control-Allow-Credentials", "true")
|
||||
reply.header("Vary", "Origin")
|
||||
}
|
||||
|
||||
reply.header("Access-Control-Expose-Headers", "Authorization, Content-Disposition, Content-Type, Content-Length")
|
||||
}
|
||||
|
||||
const accountWhere = (tenantId: number, userId: string, id?: string) => {
|
||||
const conditions = [
|
||||
eq(userCredentials.tenantId, tenantId),
|
||||
eq(userCredentials.userId, userId),
|
||||
eq(userCredentials.type, "mail"),
|
||||
]
|
||||
if (id) conditions.push(eq(userCredentials.id, id))
|
||||
return and(...conditions)
|
||||
}
|
||||
|
||||
|
||||
// ======================================================================
|
||||
@@ -28,34 +83,49 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
const body = req.body as {
|
||||
email: string
|
||||
password: string
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_ssl: boolean
|
||||
imap_host: string
|
||||
imap_port: number
|
||||
imap_ssl: boolean
|
||||
smtpHost?: string
|
||||
smtpPort?: number
|
||||
smtpSsl?: boolean
|
||||
imapHost?: string
|
||||
imapPort?: number
|
||||
imapSsl?: boolean
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
smtp_ssl?: boolean
|
||||
imap_host?: string
|
||||
imap_port?: number
|
||||
imap_ssl?: boolean
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// UPDATE EXISTING
|
||||
// -----------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select({ id: userCredentials.id })
|
||||
.from(userCredentials)
|
||||
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) return reply.code(404).send({ error: "Account not found" })
|
||||
|
||||
const saveData = {
|
||||
emailEncrypted: body.email ? encrypt(body.email) : undefined,
|
||||
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
|
||||
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
|
||||
smtpPort: body.smtp_port,
|
||||
smtpSsl: body.smtp_ssl,
|
||||
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
|
||||
imapPort: body.imap_port,
|
||||
imapSsl: body.imap_ssl,
|
||||
smtpHostEncrypted: bodyValue(body, "smtpHost", "smtp_host") ? encrypt(bodyValue(body, "smtpHost", "smtp_host")) : undefined,
|
||||
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
|
||||
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
|
||||
imapHostEncrypted: bodyValue(body, "imapHost", "imap_host") ? encrypt(bodyValue(body, "imapHost", "imap_host")) : undefined,
|
||||
imapPort: bodyValue(body, "imapPort", "imap_port"),
|
||||
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
await server.db
|
||||
.update(userCredentials)
|
||||
//@ts-ignore
|
||||
.set(saveData)
|
||||
.where(eq(userCredentials.id, id))
|
||||
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
||||
|
||||
return reply.send({ success: true })
|
||||
}
|
||||
@@ -71,13 +141,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
emailEncrypted: encrypt(body.email),
|
||||
passwordEncrypted: encrypt(body.password),
|
||||
|
||||
smtpHostEncrypted: encrypt(body.smtp_host),
|
||||
smtpPort: body.smtp_port,
|
||||
smtpSsl: body.smtp_ssl,
|
||||
smtpHostEncrypted: encrypt(bodyValue(body, "smtpHost", "smtp_host")),
|
||||
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
|
||||
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
|
||||
|
||||
imapHostEncrypted: encrypt(body.imap_host),
|
||||
imapPort: body.imap_port,
|
||||
imapSsl: body.imap_ssl,
|
||||
imapHostEncrypted: encrypt(bodyValue(body, "imapHost", "imap_host")),
|
||||
imapPort: bodyValue(body, "imapPort", "imap_port"),
|
||||
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
@@ -110,24 +180,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.id, id))
|
||||
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const returnData: any = {}
|
||||
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
if (key.endsWith("Encrypted")) {
|
||||
const cleanKey = key.replace("Encrypted", "")
|
||||
// @ts-ignore
|
||||
returnData[cleanKey] = decrypt(val as string)
|
||||
} else {
|
||||
returnData[key] = val
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send(returnData)
|
||||
return reply.send(accountResponse(row))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -136,24 +195,9 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.tenantId, req.user.tenant_id))
|
||||
.where(accountWhere(req.user.tenant_id, req.user.user_id))
|
||||
|
||||
const accounts = rows.map(row => {
|
||||
const temp: any = {}
|
||||
console.log(row)
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
console.log(key,val)
|
||||
if (key.endsWith("Encrypted") && val) {
|
||||
// @ts-ignore
|
||||
temp[key.replace("Encrypted", "")] = decrypt(val)
|
||||
} else {
|
||||
temp[key] = val
|
||||
}
|
||||
})
|
||||
return temp
|
||||
})
|
||||
|
||||
return reply.send(accounts)
|
||||
return reply.send(rows.map(accountResponse))
|
||||
|
||||
} catch (err) {
|
||||
console.error("GET /email/accounts error:", err)
|
||||
@@ -183,21 +227,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.id, body.account))
|
||||
.where(accountWhere(req.user.tenant_id, req.user.user_id, body.account))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return reply.code(404).send({ error: "Account not found" })
|
||||
|
||||
const accountData: any = {}
|
||||
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
if (key.endsWith("Encrypted") && val) {
|
||||
// @ts-ignore
|
||||
accountData[key.replace("Encrypted", "")] = decrypt(val as string)
|
||||
} else {
|
||||
accountData[key] = val
|
||||
}
|
||||
})
|
||||
const accountData = accountCredentials(row)
|
||||
|
||||
// -------------------------
|
||||
// SEND EMAIL VIA SMTP
|
||||
@@ -243,14 +279,31 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
const mail = new MailComposer(message)
|
||||
const raw = await mail.compile().build()
|
||||
|
||||
let savedToSent = false
|
||||
for await (const mailbox of await imap.list()) {
|
||||
if (mailbox.specialUse === "\\Sent") {
|
||||
await imap.mailboxOpen(mailbox.path)
|
||||
await imap.append(mailbox.path, raw, ["\\Seen"])
|
||||
await imap.logout()
|
||||
savedToSent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!savedToSent) {
|
||||
const sentFallbacks = ["Sent", "Gesendet", "INBOX.Sent"]
|
||||
for (const path of sentFallbacks) {
|
||||
try {
|
||||
await imap.append(path, raw, ["\\Seen"])
|
||||
savedToSent = true
|
||||
break
|
||||
} catch (err) {
|
||||
// Fallback wird nur genutzt, wenn der Ordner existiert.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await imap.logout()
|
||||
|
||||
return reply.send({ success: true })
|
||||
|
||||
} catch (err) {
|
||||
@@ -259,4 +312,195 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/email/accounts/:id/sync", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const body = (req.body || {}) as { mailbox?: string; limit?: number }
|
||||
|
||||
const result = await emailSync.syncAccount(
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
id,
|
||||
body,
|
||||
)
|
||||
|
||||
return reply.send({ success: true, ...result })
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "E-Mail Sync fehlgeschlagen" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/email/accounts/:id/mailboxes", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
return reply.send(await emailSync.listMailboxes(req.user.tenant_id, req.user.user_id, id))
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "Postfächer konnten nicht geladen werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/email/accounts/:id/messages", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const query = req.query as { mailbox?: string; limit?: string }
|
||||
|
||||
return reply.send(await emailSync.listMessages(
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
id,
|
||||
query.mailbox || "INBOX",
|
||||
Number(query.limit || 50),
|
||||
))
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "E-Mails konnten nicht geladen werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/email/messages/:id", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
|
||||
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||
|
||||
return reply.send(message)
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht geladen werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/email/messages/:id/read", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const body = (req.body || {}) as { seen?: boolean }
|
||||
const message = await emailSync.setMessageSeen(
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
id,
|
||||
body.seen !== false,
|
||||
)
|
||||
|
||||
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||
|
||||
return reply.send({ success: true, message })
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "Lesestatus konnte nicht synchronisiert werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/email/messages/:id/move", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const body = (req.body || {}) as { mailbox?: string }
|
||||
|
||||
if (!body.mailbox) {
|
||||
return reply.code(400).send({ error: "Zielordner fehlt" })
|
||||
}
|
||||
|
||||
const result = await emailSync.moveMessage(
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
id,
|
||||
body.mailbox,
|
||||
)
|
||||
|
||||
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||
return reply.send({ success: true, ...result })
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht verschoben werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/email/messages/:id/archive", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const result = await emailSync.archiveMessage(req.user.tenant_id, req.user.user_id, id)
|
||||
|
||||
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||
return reply.send({ success: true, ...result })
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht archiviert werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.delete("/email/messages/:id", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const result = await emailSync.deleteMessage(req.user.tenant_id, req.user.user_id, id)
|
||||
|
||||
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||
return reply.send({ success: true })
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht gelöscht werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/email/attachments/:id/download", async (req, reply) => {
|
||||
applyDownloadCorsHeaders(req, reply)
|
||||
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const attachment = await emailSync.getAttachmentContent(req.user.tenant_id, req.user.user_id, id)
|
||||
|
||||
if (!attachment) return reply.code(404).send({ error: "Anhang nicht gefunden" })
|
||||
|
||||
const buffer = Buffer.isBuffer(attachment.content)
|
||||
? attachment.content
|
||||
: Buffer.from(attachment.content)
|
||||
const filename = attachment.filename.replace(/["\r\n]/g, "")
|
||||
|
||||
reply.header("Content-Type", attachment.contentType || "application/octet-stream")
|
||||
reply.header("Content-Length", buffer.length)
|
||||
reply.header("Cache-Control", "no-store")
|
||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
return reply.send(buffer)
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "Anhang konnte nicht geladen werden" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
249
backend/src/routes/instanceAgentGateway.ts
Normal file
249
backend/src/routes/instanceAgentGateway.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
209
backend/src/routes/instanceAgents.ts
Normal file
209
backend/src/routes/instanceAgents.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { authUsers } from "../../db/schema"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||
import { pushServerClient } from "../modules/push-server.client"
|
||||
|
||||
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||
const rows = await server.db
|
||||
@@ -60,6 +61,104 @@ export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
return await svc.disablePushSubscription(requireTenant(req.user.tenant_id), req.user.user_id, body.endpoint)
|
||||
})
|
||||
|
||||
server.post("/notifications/push/mobile/register", async (req) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const body = (req.body || {}) as {
|
||||
localDeviceId?: string
|
||||
platform?: "ios" | "android"
|
||||
providerToken?: string
|
||||
deviceLabel?: string
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
if (!body.localDeviceId) throw new Error("localDeviceId fehlt")
|
||||
if (body.platform !== "ios" && body.platform !== "android") throw new Error("platform ist ungültig")
|
||||
if (!body.providerToken) throw new Error("providerToken fehlt")
|
||||
|
||||
const centralLocalDeviceId = `${tenantId}:${req.user.user_id}:${body.localDeviceId}`
|
||||
const registered = await pushServerClient.registerDevice({
|
||||
localDeviceId: centralLocalDeviceId,
|
||||
platform: body.platform,
|
||||
providerToken: body.providerToken,
|
||||
meta: {
|
||||
...(body.meta || {}),
|
||||
tenantId,
|
||||
userId: req.user.user_id,
|
||||
source: "fedeo-mobile",
|
||||
},
|
||||
})
|
||||
|
||||
const rows = await server.db
|
||||
.insert(notificationMobilePushDevices)
|
||||
.values({
|
||||
tenantId,
|
||||
userId: req.user.user_id,
|
||||
localDeviceId: body.localDeviceId,
|
||||
centralDeviceId: registered.centralDeviceId,
|
||||
platform: body.platform,
|
||||
providerTokenPreview: previewToken(body.providerToken),
|
||||
deviceLabel: body.deviceLabel,
|
||||
meta: body.meta ?? null,
|
||||
lastSeenAt: new Date(),
|
||||
disabledAt: null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
notificationMobilePushDevices.tenantId,
|
||||
notificationMobilePushDevices.userId,
|
||||
notificationMobilePushDevices.localDeviceId,
|
||||
],
|
||||
set: {
|
||||
centralDeviceId: registered.centralDeviceId,
|
||||
platform: body.platform,
|
||||
providerTokenPreview: previewToken(body.providerToken),
|
||||
deviceLabel: body.deviceLabel,
|
||||
meta: body.meta ?? null,
|
||||
lastSeenAt: new Date(),
|
||||
disabledAt: null,
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: rows[0]?.id,
|
||||
centralDeviceId: registered.centralDeviceId,
|
||||
status: registered.status,
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/notifications/test-mobile-push", async (req) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const devices = await server.db
|
||||
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
|
||||
.from(notificationMobilePushDevices)
|
||||
.where(and(
|
||||
eq(notificationMobilePushDevices.tenantId, tenantId),
|
||||
eq(notificationMobilePushDevices.userId, req.user.user_id),
|
||||
isNull(notificationMobilePushDevices.disabledAt)
|
||||
))
|
||||
|
||||
if (!devices.length) {
|
||||
throw new Error("Kein registriertes mobiles Push-Gerät gefunden")
|
||||
}
|
||||
|
||||
return await pushServerClient.sendPush({
|
||||
idempotencyKey: `mobile-test:${tenantId}:${req.user.user_id}:${Date.now()}`,
|
||||
devices: devices.map((device) => device.centralDeviceId),
|
||||
priority: "high",
|
||||
ttlSeconds: 600,
|
||||
notification: {
|
||||
title: "FEDEO Mobile Push ist aktiv",
|
||||
body: "Diese Testnachricht wurde über den zentralen Push-Server zugestellt.",
|
||||
},
|
||||
data: {
|
||||
type: "system.test_mobile_push",
|
||||
link: "/",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
server.post("/notifications/test-push", async (req) => {
|
||||
return await svc.trigger({
|
||||
tenantId: requireTenant(req.user.tenant_id),
|
||||
@@ -90,3 +189,8 @@ export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function previewToken(token: string) {
|
||||
if (token.length <= 14) return token
|
||||
return `${token.slice(0, 6)}...${token.slice(-6)}`
|
||||
}
|
||||
|
||||
1465
backend/src/routes/telephony.ts
Normal file
1465
backend/src/routes/telephony.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,21 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
// -------------------------------------------------------------
|
||||
// GET CURRENT TENANT
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant", async (req) => {
|
||||
server.get("/tenant", async (req, reply) => {
|
||||
if (req.user?.tenant_id) {
|
||||
const tenantRows = await server.db
|
||||
.select()
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, Number(req.user.tenant_id)))
|
||||
.limit(1)
|
||||
|
||||
if (!tenantRows.length) {
|
||||
return reply.code(404).send({ error: "Tenant not found" })
|
||||
}
|
||||
|
||||
return tenantRows[0]
|
||||
}
|
||||
|
||||
if (req.tenant) {
|
||||
return {
|
||||
message: `Hallo vom Tenant ${req.tenant?.name}`,
|
||||
|
||||
@@ -78,7 +78,7 @@ export const resourceConfig = {
|
||||
table: contracts,
|
||||
searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
|
||||
numberRangeHolder: "contractNumber",
|
||||
mtoLoad: ["customer", "contracttype", "outgoingsepamandate"],
|
||||
mtoLoad: ["customer", "contracttype", "contact", "outgoingsepamandate"],
|
||||
},
|
||||
outgoingsepamandates: {
|
||||
table: outgoingsepamandates,
|
||||
@@ -230,7 +230,7 @@ export const resourceConfig = {
|
||||
},
|
||||
createddocuments: {
|
||||
table: createddocuments,
|
||||
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument", "outgoingsepamandate"],
|
||||
mtoLoad: ["customer", "project", "costcentre", "contact", "contract", "plant","letterhead","createddocument", "outgoingsepamandate"],
|
||||
mtmLoad: ["statementallocations","files","createddocuments"],
|
||||
mtmListLoad: ["statementallocations", "files"],
|
||||
},
|
||||
|
||||
@@ -50,6 +50,9 @@ export let secrets = {
|
||||
WEB_PUSH_PUBLIC_KEY?: string
|
||||
WEB_PUSH_PRIVATE_KEY?: string
|
||||
WEB_PUSH_SUBJECT?: string
|
||||
PUSH_SERVER_URL?: string
|
||||
PUSH_SERVER_INSTANCE_ID?: string
|
||||
PUSH_SERVER_SECRET?: string
|
||||
}
|
||||
|
||||
const secretKeys = [
|
||||
@@ -94,6 +97,9 @@ const secretKeys = [
|
||||
"WEB_PUSH_PUBLIC_KEY",
|
||||
"WEB_PUSH_PRIVATE_KEY",
|
||||
"WEB_PUSH_SUBJECT",
|
||||
"PUSH_SERVER_URL",
|
||||
"PUSH_SERVER_INSTANCE_ID",
|
||||
"PUSH_SERVER_SECRET",
|
||||
] as const
|
||||
|
||||
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
|
||||
|
||||
@@ -111,6 +111,11 @@ services:
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
FEDEO_FILE_BACKEND: ${FEDEO_FILE_BACKEND:-s3}
|
||||
SEAFILE_BASE_URL: ${SEAFILE_BASE_URL}
|
||||
SEAFILE_INTERNAL_URL: ${SEAFILE_INTERNAL_URL}
|
||||
SEAFILE_ADMIN_EMAIL: ${SEAFILE_ADMIN_EMAIL}
|
||||
SEAFILE_ADMIN_PASSWORD: ${SEAFILE_ADMIN_PASSWORD}
|
||||
M2M_API_KEY: ${M2M_API_KEY}
|
||||
API_BASE_URL: ${API_BASE_URL}
|
||||
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
|
||||
@@ -185,6 +190,7 @@ services:
|
||||
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
|
||||
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
||||
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.fedeo-frontend.priority=1
|
||||
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
||||
- traefik.docker.network=fedeo_web
|
||||
networks:
|
||||
@@ -442,6 +448,7 @@ services:
|
||||
matrix-element:
|
||||
image: vectorim/element-web:latest
|
||||
container_name: fedeo-matrix-element
|
||||
user: "0:0"
|
||||
restart: unless-stopped
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
|
||||
29
docker-compose.website.yml
Normal file
29
docker-compose.website.yml
Normal 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
|
||||
@@ -57,6 +57,21 @@ services:
|
||||
- WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-}
|
||||
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
|
||||
- NODE_EXPORTER_URL=${NODE_EXPORTER_URL:-http://node-exporter:9100}
|
||||
- TELEPHONY_ENABLED=${TELEPHONY_ENABLED:-false}
|
||||
- TELEPHONY_ASTERISK_HTTP_URL=${TELEPHONY_ASTERISK_HTTP_URL:-}
|
||||
- TELEPHONY_ASTERISK_WS_URL=${TELEPHONY_ASTERISK_WS_URL:-}
|
||||
- TELEPHONY_SIP_DOMAIN=${TELEPHONY_SIP_DOMAIN:-localhost}
|
||||
- TELEPHONY_ECHO_EXTENSION=${TELEPHONY_ECHO_EXTENSION:-600}
|
||||
- TELEPHONY_EXTERNAL_PROVIDER=${TELEPHONY_EXTERNAL_PROVIDER:-}
|
||||
- TELEPHONY_EXTERNAL_ENABLED=${TELEPHONY_EXTERNAL_ENABLED:-false}
|
||||
- TELEPHONY_EXTERNAL_INBOUND_EXTENSION=${TELEPHONY_EXTERNAL_INBOUND_EXTENSION:-1001}
|
||||
- TELEPHONY_ASTERISK_GENERATED_DIR=${TELEPHONY_ASTERISK_GENERATED_DIR:-/var/lib/fedeo/asterisk/generated}
|
||||
- TELEPHONY_ASTERISK_AMI_HOST=${TELEPHONY_ASTERISK_AMI_HOST:-}
|
||||
- TELEPHONY_ASTERISK_AMI_PORT=${TELEPHONY_ASTERISK_AMI_PORT:-5038}
|
||||
- TELEPHONY_ASTERISK_AMI_USER=${TELEPHONY_ASTERISK_AMI_USER:-fedeo}
|
||||
- TELEPHONY_ASTERISK_AMI_PASSWORD=${TELEPHONY_ASTERISK_AMI_PASSWORD:-fedeo-ami-dev}
|
||||
volumes:
|
||||
- ./telephony/generated:/var/lib/fedeo/asterisk/generated
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
|
||||
@@ -5,3 +5,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||
## Einstieg
|
||||
|
||||
- [Bedienung](./bedienung/README.md)
|
||||
- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md)
|
||||
|
||||
371
docs-site/content/kommunikationslösung-matrix.md
Normal file
371
docs-site/content/kommunikationslösung-matrix.md
Normal 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/
|
||||
103
docs-site/content/telekom-telefonie.md
Normal file
103
docs-site/content/telekom-telefonie.md
Normal 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.
|
||||
17
docs-site/content/vps-asterisk-dev.md
Normal file
17
docs-site/content/vps-asterisk-dev.md
Normal 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.
|
||||
@@ -6,3 +6,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||
|
||||
- [Bedienung](./bedienung/README.md)
|
||||
- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md)
|
||||
- [Zentraler Push-Server für Selfhost-Instanzen](./zentraler-push-server.md)
|
||||
|
||||
59
docs/bedienung/gmail-anhaenge-herunterladen.md
Normal file
59
docs/bedienung/gmail-anhaenge-herunterladen.md
Normal 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
71
docs/telekom-telefonie.md
Normal 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
17
docs/vps-asterisk-dev.md
Normal 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.
|
||||
429
docs/zentraler-push-server.md
Normal file
429
docs/zentraler-push-server.md
Normal 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.
|
||||
@@ -400,6 +400,32 @@ const canArchiveItem = computed(() => {
|
||||
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 () => {
|
||||
let ret = null
|
||||
|
||||
@@ -1036,6 +1062,16 @@ const updateItem = async () => {
|
||||
</InputGroup>
|
||||
</UFormField>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -60,6 +60,22 @@ const renderDatapointValue = (datapoint) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -84,6 +100,12 @@ const renderDatapointValue = (datapoint) => {
|
||||
<td>{{datapoint.label}}:</td>
|
||||
<td>
|
||||
<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>
|
||||
<span>{{ renderDatapointValue(datapoint) }}</span>
|
||||
</div>
|
||||
|
||||
324
frontend/components/FileScanModal.vue
Normal file
324
frontend/components/FileScanModal.vue
Normal 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>
|
||||
@@ -80,6 +80,11 @@ const links = computed(() => {
|
||||
to: "/communication/chat",
|
||||
icon: "i-heroicons-chat-bubble-left-right"
|
||||
},
|
||||
{
|
||||
label: "Telefonie",
|
||||
to: "/communication/phone",
|
||||
icon: "i-heroicons-phone"
|
||||
},
|
||||
featureEnabled("helpdesk") ? {
|
||||
label: "Helpdesk",
|
||||
to: "/helpdesk",
|
||||
@@ -88,9 +93,8 @@ const links = computed(() => {
|
||||
} : null,
|
||||
featureEnabled("email") ? {
|
||||
label: "E-Mail",
|
||||
to: "/email/new",
|
||||
to: "/email",
|
||||
icon: "i-heroicons-envelope",
|
||||
disabled: true
|
||||
} : null,
|
||||
]
|
||||
|
||||
@@ -332,6 +336,11 @@ const links = computed(() => {
|
||||
to: "/settings/tenant",
|
||||
icon: "i-heroicons-building-office",
|
||||
} : null,
|
||||
{
|
||||
label: "Telefonie",
|
||||
to: "/settings/telephony",
|
||||
icon: "i-heroicons-phone",
|
||||
},
|
||||
{
|
||||
label: "Matrix-Setup",
|
||||
to: "/communication",
|
||||
@@ -355,6 +364,11 @@ const links = computed(() => {
|
||||
to: "/administration/tenants",
|
||||
icon: "i-heroicons-building-office-2",
|
||||
},
|
||||
{
|
||||
label: "Scanner",
|
||||
to: "/administration/scanners",
|
||||
icon: "i-heroicons-printer",
|
||||
},
|
||||
{
|
||||
label: "Systemstatus",
|
||||
to: "/administration/system",
|
||||
|
||||
51
frontend/components/TelephonyCallButton.vue
Normal file
51
frontend/components/TelephonyCallButton.vue
Normal 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>
|
||||
70
frontend/components/TelephonyCallOverlay.vue
Normal file
70
frontend/components/TelephonyCallOverlay.vue
Normal 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>
|
||||
197
frontend/components/TelephonyExtensionField.vue
Normal file
197
frontend/components/TelephonyExtensionField.vue
Normal 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>
|
||||
@@ -54,6 +54,7 @@ const optionsToImport = ref({
|
||||
contactPerson: true,
|
||||
plant: true,
|
||||
project:true,
|
||||
costcentre: true,
|
||||
description: true,
|
||||
startText: false,
|
||||
rows: true,
|
||||
@@ -74,6 +75,7 @@ const mappings = ref({
|
||||
contactPerson: "Ansprechpartner Mitarbeiter",
|
||||
plant: "Objekt",
|
||||
project: "Projekt",
|
||||
costcentre: "Kostenstelle",
|
||||
description: "Beschreibung",
|
||||
startText: "Einleitung",
|
||||
rows: "Positionen",
|
||||
|
||||
@@ -10,6 +10,7 @@ const props = defineProps({
|
||||
|
||||
const loading = ref(true)
|
||||
const incomingInvoices = ref([])
|
||||
const createddocuments = ref([])
|
||||
const costcentres = ref([])
|
||||
const selectedYear = ref(String(dayjs().year()))
|
||||
const selectedMonth = ref("all")
|
||||
@@ -98,7 +99,7 @@ const monthItems = [
|
||||
]
|
||||
|
||||
const reportRows = computed(() => {
|
||||
return incomingInvoices.value.flatMap((invoice) => {
|
||||
const incomingRows = incomingInvoices.value.flatMap((invoice) => {
|
||||
const invoiceDate = invoice.date ? dayjs(invoice.date) : null
|
||||
|
||||
if (invoiceDate && invoiceDate.year().toString() !== selectedYear.value) {
|
||||
@@ -120,7 +121,8 @@ const reportRows = computed(() => {
|
||||
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
|
||||
|
||||
return {
|
||||
id: `${invoice.id}-${index}`,
|
||||
id: `incoming-${invoice.id}-${index}`,
|
||||
sourceLabel: "Eingangsbeleg",
|
||||
invoiceId: invoice.id,
|
||||
reference: invoice.reference || "-",
|
||||
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(() => {
|
||||
@@ -147,9 +196,10 @@ const totals = computed(() => {
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: "sourceLabel", header: "Art" },
|
||||
{ accessorKey: "reference", header: "Beleg" },
|
||||
{ accessorKey: "date", header: "Datum" },
|
||||
{ accessorKey: "vendorName", header: "Lieferant" },
|
||||
{ accessorKey: "vendorName", header: "Kontakt" },
|
||||
{ accessorKey: "accountLabel", header: "Konto" },
|
||||
{ accessorKey: "costCentreName", header: "Kostenstelle" },
|
||||
{ accessorKey: "description", header: "Beschreibung" },
|
||||
@@ -163,10 +213,16 @@ const setupPage = async () => {
|
||||
|
||||
costcentres.value = await useEntities("costcentres").select("*", null, false, true)
|
||||
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
|
||||
const documents = await useEntities("createddocuments").select("*, customer(id,name)")
|
||||
|
||||
incomingInvoices.value = invoices.filter((invoice) =>
|
||||
(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
|
||||
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>
|
||||
</template>
|
||||
<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>
|
||||
</UTable>
|
||||
|
||||
|
||||
@@ -83,11 +83,39 @@ export type SystemStatus = {
|
||||
}>
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
const { $api } = useNuxtApp()
|
||||
|
||||
const getOverview = async (): Promise<AdminOverview> => {
|
||||
const response = await $api("/api/admin/overview")
|
||||
const response = await $api("/api/admin/overview") as any
|
||||
|
||||
return {
|
||||
users: response?.users || [],
|
||||
@@ -162,9 +190,31 @@ export const useAdmin = () => {
|
||||
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 {
|
||||
getOverview,
|
||||
getSystemStatus,
|
||||
getInstanceAgents,
|
||||
createInstanceAgent,
|
||||
updateInstanceAgent,
|
||||
createUser,
|
||||
createUserForProfile,
|
||||
updateUser,
|
||||
|
||||
722
frontend/composables/useTelephonySoftphone.js
Normal file
722
frontend/composables/useTelephonySoftphone.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,7 @@ onMounted(() => {
|
||||
</UDashboardPanel>
|
||||
</UDashboardGroup>
|
||||
|
||||
<TelephonyCallOverlay/>
|
||||
<HelpSlideover/>
|
||||
|
||||
<Calculator v-if="calculatorStore.isOpen"/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const auth = useAuthStore()
|
||||
const isPortalUser = Boolean(auth.profile?.customer_for_portal)
|
||||
|
||||
// DEBUG: Was sieht die Middleware wirklich?
|
||||
console.log("🔒 Middleware Check auf:", to.path)
|
||||
@@ -13,6 +12,19 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
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) {
|
||||
console.log("⏳ Auth lädt noch...")
|
||||
return
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -83,6 +83,7 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pinia": "^2.1.7",
|
||||
"sass": "^1.69.7",
|
||||
"sip.js": "^0.21.2",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss-safe-area-capacitor": "^0.5.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
@@ -19022,6 +19023,15 @@
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pinia": "^2.1.7",
|
||||
"sass": "^1.69.7",
|
||||
"sip.js": "^0.21.2",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss-safe-area-capacitor": "^0.5.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
|
||||
429
frontend/pages/administration/scanners.vue
Normal file
429
frontend/pages/administration/scanners.vue
Normal 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>
|
||||
@@ -22,11 +22,16 @@ const incominginvoices = ref([])
|
||||
const openDocuments = ref([])
|
||||
const openIncomingInvoices = ref([])
|
||||
const filterAccount = ref([])
|
||||
const filterAccountInitialized = ref(false)
|
||||
const isSyncing = ref(false)
|
||||
const cashbookBookingModalOpen = ref(false)
|
||||
const savingCashbookBooking = ref(false)
|
||||
const loadingDocs = ref(true) // Startet im Ladezustand
|
||||
const suggestionsModalOpen = ref(false)
|
||||
const selectedSuggestionRowId = ref(null)
|
||||
|
||||
const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
||||
|
||||
// Zeitraum-Optionen
|
||||
const periodOptions = [
|
||||
{label: 'Aktueller Monat', key: 'current_month'},
|
||||
@@ -52,6 +57,12 @@ const dateRange = ref({
|
||||
end: $dayjs().endOf('month').format('YYYY-MM-DD')
|
||||
})
|
||||
|
||||
const cashbookForm = reactive({
|
||||
date: $dayjs().format("YYYY-MM-DD"),
|
||||
direction: "expense",
|
||||
amount: null
|
||||
})
|
||||
|
||||
const getCalendarValue = (value) => {
|
||||
if (!value) return undefined
|
||||
|
||||
@@ -72,7 +83,7 @@ const setDateRangeFieldToToday = (field) => {
|
||||
const setupPage = async () => {
|
||||
loadingDocs.value = true
|
||||
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("bankaccounts").select(),
|
||||
useEntities("customers").select(),
|
||||
@@ -83,7 +94,10 @@ const setupPage = async () => {
|
||||
])
|
||||
|
||||
bankstatements.value = statements
|
||||
bankaccounts.value = accounts
|
||||
bankaccounts.value = (bankAccountItems || []).map((account) => ({
|
||||
...account,
|
||||
displayLabel: getBankAccountLabel(account)
|
||||
}))
|
||||
customers.value = customerItems
|
||||
vendors.value = vendorItems
|
||||
entitybankaccounts.value = entityBankItems
|
||||
@@ -102,8 +116,14 @@ const setupPage = async () => {
|
||||
openIncomingInvoices.value = invoiceItems
|
||||
.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) {
|
||||
filterAccount.value = bankaccounts.value
|
||||
const availableAccountIds = bankaccounts.value.map((account) => account.id)
|
||||
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
|
||||
@@ -179,7 +199,7 @@ const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] || ['Nur of
|
||||
const shouldShowMonthDivider = (row, index) => {
|
||||
if (index === 0) return true;
|
||||
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) => {
|
||||
@@ -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 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))]
|
||||
@@ -223,7 +300,7 @@ const getAmountMatchLabel = (openSum, remaining) => {
|
||||
const difference = Math.abs(Math.abs(Number(openSum)) - Math.abs(Number(remaining)))
|
||||
if (difference < 0.01) return "Betrag exakt"
|
||||
if (difference <= 1) return "Betrag fast passend"
|
||||
if (difference <= 5) return "Betrag aehnlich"
|
||||
if (difference <= 5) return "Betrag ähnlich"
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -276,7 +353,7 @@ const getTopEntitySuggestion = (statement) => {
|
||||
reason = "Name passt"
|
||||
} else if (partialNameMatch) {
|
||||
score = 45
|
||||
reason = "Name aehnlich"
|
||||
reason = "Name ähnlich"
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -401,10 +478,10 @@ const filteredRows = computed(() => {
|
||||
|
||||
// Filterung nach Datum
|
||||
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) {
|
||||
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
|
||||
@@ -419,13 +496,13 @@ const filteredRows = computed(() => {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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(".", ",")} €`
|
||||
@@ -492,13 +569,22 @@ onMounted(() => {
|
||||
<template>
|
||||
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
|
||||
<template #right>
|
||||
<UButton
|
||||
v-if="selectedCashbookAccount"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
icon="i-heroicons-plus"
|
||||
@click="cashbookBookingModalOpen = true"
|
||||
>
|
||||
Eintrag hinzufügen
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-sparkles"
|
||||
@click="suggestionsModalOpen = true"
|
||||
>
|
||||
Vorschlaege
|
||||
Vorschläge
|
||||
<UBadge color="primary" variant="solid" size="xs" class="ml-2">{{ suggestionCount }}</UBadge>
|
||||
</UButton>
|
||||
<UButton
|
||||
@@ -525,14 +611,13 @@ onMounted(() => {
|
||||
:items="bankaccounts"
|
||||
v-model="filterAccount"
|
||||
value-key="id"
|
||||
label-key="iban"
|
||||
label-key="displayLabel"
|
||||
multiple
|
||||
by="id"
|
||||
placeholder="Konten"
|
||||
class="w-48"
|
||||
class="w-64"
|
||||
>
|
||||
<template #default>
|
||||
{{ filterAccount.length > 0 ? `${filterAccount.length} Kont${filterAccount.length > 1 ? 'en' : 'o'}` : 'Konten' }}
|
||||
{{ selectedAccountLabel }}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-calendar" class="w-4 h-4"/>
|
||||
{{ $dayjs(row.valueDate).format('MMMM YYYY') }}
|
||||
{{ $dayjs(getStatementDate(row)).format('MMMM YYYY') }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -648,9 +733,9 @@ onMounted(() => {
|
||||
@click="router.push(`/banking/statements/edit/${row.id}`)"
|
||||
>
|
||||
<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 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">
|
||||
<span :class="row.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-rose-600 dark:text-rose-400'">
|
||||
{{ displayCurrency(row.amount) }}
|
||||
@@ -680,13 +765,82 @@ onMounted(() => {
|
||||
</div>
|
||||
<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' }">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<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>
|
||||
<UBadge color="primary" variant="subtle">{{ suggestionCount }}</UBadge>
|
||||
@@ -724,7 +878,7 @@ onMounted(() => {
|
||||
<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-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 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>
|
||||
@@ -783,7 +937,7 @@ onMounted(() => {
|
||||
|
||||
<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"/>
|
||||
<p>Keine Vorschlaege fuer die aktuelle Filterung vorhanden.</p>
|
||||
<p>Keine Vorschläge für die aktuelle Filterung vorhanden.</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
@@ -70,6 +70,7 @@ let matrixLiveKitRoom = null
|
||||
let matrixLiveSyncRunId = 0
|
||||
const matrixCallVideoElements = new Map()
|
||||
const matrixAttachmentPreviewRequests = new Set()
|
||||
const matrixAttachmentPreviewFailures = new Set()
|
||||
const matrixSyncSince = ref("")
|
||||
|
||||
const canUseMatrixChat = computed(() =>
|
||||
@@ -1388,7 +1389,12 @@ const attachmentObjectUrl = (attachment) =>
|
||||
attachment?.url ? matrixAttachmentObjectUrls.value[attachment.url] || "" : ""
|
||||
|
||||
const ensureAttachmentObjectUrl = async (attachment) => {
|
||||
if (!attachment?.url || matrixAttachmentObjectUrls.value[attachment.url] || matrixAttachmentPreviewRequests.has(attachment.url)) return
|
||||
if (
|
||||
!attachment?.url ||
|
||||
matrixAttachmentObjectUrls.value[attachment.url] ||
|
||||
matrixAttachmentPreviewRequests.has(attachment.url) ||
|
||||
matrixAttachmentPreviewFailures.has(attachment.url)
|
||||
) return
|
||||
|
||||
matrixAttachmentPreviewRequests.add(attachment.url)
|
||||
try {
|
||||
@@ -1401,6 +1407,7 @@ const ensureAttachmentObjectUrl = async (attachment) => {
|
||||
[attachment.url]: objectUrl
|
||||
}
|
||||
} catch (error) {
|
||||
matrixAttachmentPreviewFailures.add(attachment.url)
|
||||
// Fällt nur auf den Link zurück; der Chat selbst soll weiter funktionieren.
|
||||
} finally {
|
||||
matrixAttachmentPreviewRequests.delete(attachment.url)
|
||||
|
||||
7
frontend/pages/communication/phone-setup.vue
Normal file
7
frontend/pages/communication/phone-setup.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
await navigateTo("/settings/telephony", { replace: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
391
frontend/pages/communication/phone.vue
Normal file
391
frontend/pages/communication/phone.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<script setup>
|
||||
const {
|
||||
loading,
|
||||
callHistoryLoading,
|
||||
config,
|
||||
callHistory,
|
||||
selectedExtension,
|
||||
dialTarget,
|
||||
activeSession,
|
||||
remoteAudio,
|
||||
sipLoading,
|
||||
sipRegistered,
|
||||
sipStatus,
|
||||
sipError,
|
||||
callState,
|
||||
registererState,
|
||||
canRegisterSip,
|
||||
canStartCall,
|
||||
canHangup,
|
||||
loadTelephony,
|
||||
loadCallHistory,
|
||||
registerSip,
|
||||
stopSip,
|
||||
startCall,
|
||||
hangupCall,
|
||||
} = useTelephonySoftphone()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const historyFilters = reactive({
|
||||
direction: "all",
|
||||
status: "all",
|
||||
search: ""
|
||||
})
|
||||
|
||||
const directionOptions = [
|
||||
{ label: "Alle Richtungen", value: "all" },
|
||||
{ label: "Eingehend", value: "incoming" },
|
||||
{ label: "Ausgehend", value: "outgoing" }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "Alle Status", value: "all" },
|
||||
{ label: "Aktiv", value: "active" },
|
||||
{ label: "Beendet", value: "completed" },
|
||||
{ label: "Verpasst", value: "missed" },
|
||||
{ label: "Fehlgeschlagen", value: "failed" },
|
||||
{ label: "Abgebrochen", value: "canceled" }
|
||||
]
|
||||
|
||||
const callStatusLabel = (status) => ({
|
||||
ringing: "Klingelt",
|
||||
dialing: "Wählt",
|
||||
active: "Aktiv",
|
||||
completed: "Beendet",
|
||||
missed: "Verpasst",
|
||||
rejected: "Abgelehnt",
|
||||
canceled: "Abgebrochen",
|
||||
failed: "Fehlgeschlagen",
|
||||
}[status] || status || "Unbekannt")
|
||||
|
||||
const callStatusColor = (status) => ({
|
||||
completed: "success",
|
||||
active: "primary",
|
||||
ringing: "primary",
|
||||
dialing: "primary",
|
||||
missed: "warning",
|
||||
rejected: "neutral",
|
||||
canceled: "neutral",
|
||||
failed: "error",
|
||||
}[status] || "neutral")
|
||||
|
||||
const formatCallTime = (value) => {
|
||||
if (!value) return "-"
|
||||
return new Date(value).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return "-"
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = seconds % 60
|
||||
return `${minutes}:${String(rest).padStart(2, "0")} Min.`
|
||||
}
|
||||
|
||||
const historyFilterParams = () => ({
|
||||
direction: historyFilters.direction !== "all" ? historyFilters.direction : undefined,
|
||||
status: historyFilters.status !== "all" ? historyFilters.status : undefined,
|
||||
search: historyFilters.search?.trim() || undefined
|
||||
})
|
||||
|
||||
const loadFilteredCallHistory = () => loadCallHistory(historyFilterParams())
|
||||
|
||||
const setDialTargetFromQuery = () => {
|
||||
if (route.query.call) {
|
||||
dialTarget.value = String(route.query.call)
|
||||
}
|
||||
}
|
||||
|
||||
const callFromHistory = (call) => {
|
||||
if (!call?.remoteNumber) return
|
||||
dialTarget.value = call.remoteNumber
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
setDialTargetFromQuery()
|
||||
await loadTelephony()
|
||||
})
|
||||
|
||||
watch(() => route.query.call, setDialTargetFromQuery)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto flex max-w-7xl flex-col gap-6">
|
||||
<div class="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary-600">
|
||||
Kommunikation
|
||||
</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-gray-950">
|
||||
Telefonie
|
||||
</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-gray-600">
|
||||
Anrufe direkt in FEDEO starten und annehmen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
variant="soft"
|
||||
:loading="loading"
|
||||
@click="loadTelephony"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
<UButton
|
||||
to="/settings/telephony"
|
||||
icon="i-heroicons-cog-6-tooth"
|
||||
variant="outline"
|
||||
>
|
||||
Einstellungen
|
||||
</UButton>
|
||||
<UButton
|
||||
to="/communication/chat"
|
||||
icon="i-heroicons-chat-bubble-left-right"
|
||||
variant="outline"
|
||||
>
|
||||
Zum Chat
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-950">
|
||||
FEDEO Softphone
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Registrierung und Anrufe über den angebundenen Asterisk.
|
||||
</p>
|
||||
</div>
|
||||
<UBadge
|
||||
:color="sipRegistered ? 'success' : 'neutral'"
|
||||
variant="soft"
|
||||
class="w-fit"
|
||||
>
|
||||
{{ sipStatus }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">Nebenstelle</label>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="account in config?.accounts || []"
|
||||
:key="account.extension"
|
||||
type="button"
|
||||
class="rounded-lg border px-4 py-3 text-left transition"
|
||||
:class="selectedExtension === account.extension ? 'border-primary-500 bg-primary-50 text-primary-900' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'"
|
||||
:disabled="sipRegistered || sipLoading"
|
||||
@click="selectedExtension = account.extension"
|
||||
>
|
||||
<span class="block font-semibold">{{ account.extension }}</span>
|
||||
<span class="mt-1 block text-xs opacity-75">{{ account.displayName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!(config?.accounts || []).length" class="mt-2 text-sm text-gray-500">
|
||||
Für deinen Benutzer ist noch keine Nebenstelle hinterlegt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UButton
|
||||
icon="i-heroicons-phone-arrow-up-right"
|
||||
:loading="sipLoading && !sipRegistered"
|
||||
:disabled="!canRegisterSip || sipRegistered"
|
||||
@click="registerSip"
|
||||
>
|
||||
Registrieren
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-heroicons-x-circle"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:disabled="!sipRegistered || sipLoading"
|
||||
@click="stopSip"
|
||||
>
|
||||
Trennen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div class="grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<UInput
|
||||
v-model="dialTarget"
|
||||
icon="i-heroicons-hashtag"
|
||||
placeholder="Zielnummer oder Nebenstelle"
|
||||
:disabled="!sipRegistered || Boolean(activeSession)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-phone"
|
||||
:loading="sipLoading && sipRegistered"
|
||||
:disabled="!canStartCall"
|
||||
@click="startCall"
|
||||
>
|
||||
Anrufen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-phone-x-mark"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:disabled="!canHangup"
|
||||
@click="hangupCall"
|
||||
>
|
||||
Auflegen
|
||||
</UButton>
|
||||
<UBadge color="neutral" variant="soft">
|
||||
{{ callState === "active" ? "Aktiver Anruf" : callState === "connecting" ? "Verbindet" : callState === "incoming" ? "Eingehend" : "Bereit" }}
|
||||
</UBadge>
|
||||
<UBadge :color="sipRegistered ? 'success' : 'warning'" variant="soft">
|
||||
Registrierung: {{ registererState }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="sipError"
|
||||
class="mt-4"
|
||||
color="error"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
title="Telefoniefehler"
|
||||
:description="sipError"
|
||||
/>
|
||||
|
||||
<audio ref="remoteAudio" autoplay playsinline />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-950">
|
||||
Anrufhistorie
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Anrufe dieses Mandanten mit Rückruf und Filter.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UInput
|
||||
v-model="historyFilters.search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Suchen"
|
||||
class="w-44"
|
||||
@keyup.enter="loadFilteredCallHistory"
|
||||
/>
|
||||
<USelectMenu
|
||||
v-model="historyFilters.direction"
|
||||
:items="directionOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
class="w-40"
|
||||
@update:model-value="loadFilteredCallHistory"
|
||||
/>
|
||||
<USelectMenu
|
||||
v-model="historyFilters.status"
|
||||
:items="statusOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
class="w-40"
|
||||
@update:model-value="loadFilteredCallHistory"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
variant="ghost"
|
||||
:loading="callHistoryLoading"
|
||||
@click="loadFilteredCallHistory"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="callHistory.length" class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="text-left text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Zeit</th>
|
||||
<th class="px-3 py-2">Richtung</th>
|
||||
<th class="px-3 py-2">Teilnehmer</th>
|
||||
<th class="px-3 py-2">Nebenstelle</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
<th class="px-3 py-2 text-right">Dauer</th>
|
||||
<th class="px-3 py-2 text-right">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr
|
||||
v-for="call in callHistory"
|
||||
:key="call.id"
|
||||
class="bg-white"
|
||||
>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-gray-600">
|
||||
{{ formatCallTime(call.startedAt) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3">
|
||||
<UIcon
|
||||
:name="call.direction === 'incoming' ? 'i-heroicons-phone-arrow-down-left' : 'i-heroicons-phone-arrow-up-right'"
|
||||
class="h-5 w-5 text-gray-500"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="font-medium text-gray-950">
|
||||
{{ call.remoteDisplayName || call.remoteNumber || "Unbekannt" }}
|
||||
</div>
|
||||
<div v-if="call.remoteDisplayName && call.remoteNumber && call.remoteDisplayName !== call.remoteNumber" class="text-xs text-gray-500">
|
||||
{{ call.remoteNumber }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 font-mono text-gray-700">
|
||||
{{ call.localExtension || "-" }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3">
|
||||
<UBadge :color="callStatusColor(call.status)" variant="soft">
|
||||
{{ callStatusLabel(call.status) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right text-gray-600">
|
||||
{{ formatDuration(call.durationSeconds) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right">
|
||||
<UButton
|
||||
v-if="call.remoteNumber"
|
||||
icon="i-heroicons-phone"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
@click="callFromHistory(call)"
|
||||
>
|
||||
Rückruf
|
||||
</UButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-500">
|
||||
Noch keine Anrufe in der Historie.
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,6 +43,7 @@ const itemInfo = ref({
|
||||
city: null,
|
||||
},
|
||||
project: null,
|
||||
costcentre: null,
|
||||
documentNumber: null,
|
||||
documentNumberTitle: "Rechnungsnummer",
|
||||
documentDate: dayjs(),
|
||||
@@ -83,6 +84,7 @@ const letterheads = ref([])
|
||||
const createddocuments = ref([])
|
||||
const projects = ref([])
|
||||
const plants = ref([])
|
||||
const costcentres = ref([])
|
||||
const products = ref([])
|
||||
const productcategories = ref([])
|
||||
const selectedProductcategorie = ref(null)
|
||||
@@ -126,6 +128,10 @@ const getContractNumber = (id) => findById(contracts.value, id)?.contractNumber
|
||||
const getLetterheadName = (id) => findById(letterheads.value, id)?.name || "Briefpapier nicht gefunden"
|
||||
const getPlantName = (id) => findById(plants.value, id)?.name || "Objekt nicht gefunden"
|
||||
const getProjectName = (id) => findById(projects.value, id)?.name || "Projekt nicht gefunden"
|
||||
const getCostCentreName = (id) => {
|
||||
const costcentre = findById(costcentres.value, id)
|
||||
return costcentre ? [costcentre.number, costcentre.name].filter(Boolean).join(" - ") : "Keine Kostenstelle ausgewählt"
|
||||
}
|
||||
const getSelectedLetterhead = () => findById(letterheads.value, itemInfo.value.letterhead)
|
||||
const normalizeExternalUrl = (value) => {
|
||||
if (!value || typeof value !== "string") return null
|
||||
@@ -216,6 +222,7 @@ const setupData = async () => {
|
||||
createddocuments.value = await useEntities("createddocuments").select("*")
|
||||
projects.value = await useEntities("projects").select("*")
|
||||
plants.value = await useEntities("plants").select("*")
|
||||
costcentres.value = await useEntities("costcentres").select("*")
|
||||
services.value = await useEntities("services").select("*")
|
||||
servicecategories.value = await useEntities("servicecategories").select("*")
|
||||
products.value = await useEntities("products").select("*")
|
||||
@@ -336,6 +343,8 @@ const normalizeCreatedDocumentRow = (row) => {
|
||||
return {
|
||||
...normalizedRow,
|
||||
id: normalizedRow.id || uuidv4(),
|
||||
costCentre: normalizedRow.costCentre || normalizedRow.costcentre || null,
|
||||
purchasePrice: Number(normalizedRow.purchasePrice || 0),
|
||||
linkedEntitys: Array.isArray(normalizedRow.linkedEntitys) ? normalizedRow.linkedEntitys : [],
|
||||
}
|
||||
}
|
||||
@@ -554,6 +563,7 @@ const setupPage = async () => {
|
||||
if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson
|
||||
if (optionsToImport.plant) itemInfo.value.plant = linkedDocument.plant
|
||||
if (optionsToImport.project) itemInfo.value.project = linkedDocument.project
|
||||
if (optionsToImport.costcentre) itemInfo.value.costcentre = linkedDocument.costcentre
|
||||
if (optionsToImport.title) itemInfo.value.title = linkedDocument.title
|
||||
if (optionsToImport.description) itemInfo.value.description = linkedDocument.description
|
||||
if (optionsToImport.startText) itemInfo.value.startText = linkedDocument.startText
|
||||
@@ -580,6 +590,7 @@ const setupPage = async () => {
|
||||
itemInfo.value.contactPerson = linkedDocument.contactPerson
|
||||
itemInfo.value.plant = linkedDocument.plant
|
||||
itemInfo.value.project = linkedDocument.project
|
||||
itemInfo.value.costcentre = linkedDocument.costcentre
|
||||
itemInfo.value.title = linkedDocument.title
|
||||
itemInfo.value.description = linkedDocument.description
|
||||
itemInfo.value.startText = linkedDocument.startText
|
||||
@@ -790,6 +801,7 @@ const importPositions = () => {
|
||||
price: advanceInvoiceData.value.part,
|
||||
taxPercent: 19,
|
||||
discountPercent: 0,
|
||||
costCentre: itemInfo.value.costcentre,
|
||||
advanceInvoiceData: advanceInvoiceData.value
|
||||
})
|
||||
setPosNumbers()
|
||||
@@ -828,9 +840,11 @@ const addPosition = (mode) => {
|
||||
quantity: 1,
|
||||
unit: 1,
|
||||
inputPrice: 0,
|
||||
purchasePrice: 0,
|
||||
price: 0,
|
||||
taxPercent: taxPercentage,
|
||||
discountPercent: 0,
|
||||
costCentre: itemInfo.value.costcentre,
|
||||
linkedEntitys: []
|
||||
}
|
||||
|
||||
@@ -846,6 +860,7 @@ const addPosition = (mode) => {
|
||||
taxPercent: taxPercentage,
|
||||
discountPercent: 0,
|
||||
unit: 1,
|
||||
costCentre: itemInfo.value.costcentre,
|
||||
linkedEntitys: []
|
||||
})
|
||||
} else if (mode === 'service') {
|
||||
@@ -858,6 +873,7 @@ const addPosition = (mode) => {
|
||||
taxPercent: taxPercentage,
|
||||
discountPercent: 0,
|
||||
unit: 1,
|
||||
costCentre: itemInfo.value.costcentre,
|
||||
linkedEntitys: []
|
||||
}
|
||||
|
||||
@@ -1167,6 +1183,8 @@ const documentReport = computed(() => {
|
||||
let product = products.value.find(i => i.id === row.product)
|
||||
|
||||
totalProductsPurchasePrice += (product?.purchase_price || 0) * row.quantity
|
||||
} else if (row.mode === "free") {
|
||||
totalProductsPurchasePrice += Number(row.purchasePrice || 0) * Number(row.quantity || 0)
|
||||
|
||||
} else if (row.service) {
|
||||
let service = services.value.find(i => i.id === row.service)
|
||||
@@ -1577,6 +1595,7 @@ const saveSerialInvoice = async () => {
|
||||
contract: normalizeEntityId(itemInfo.value.contract),
|
||||
address: itemInfo.value.address,
|
||||
project: normalizeEntityId(itemInfo.value.project),
|
||||
costcentre: itemInfo.value.costcentre,
|
||||
paymentDays: itemInfo.value.paymentDays,
|
||||
payment_type: itemInfo.value.payment_type,
|
||||
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
|
||||
@@ -1660,6 +1679,7 @@ const saveDocument = async (state, resetup = false) => {
|
||||
contract: normalizeEntityId(itemInfo.value.contract),
|
||||
address: itemInfo.value.address,
|
||||
project: normalizeEntityId(itemInfo.value.project),
|
||||
costcentre: itemInfo.value.costcentre,
|
||||
plant: normalizeEntityId(itemInfo.value.plant),
|
||||
documentNumber: itemInfo.value.documentNumber,
|
||||
documentDate: itemInfo.value.documentDate,
|
||||
@@ -1820,6 +1840,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
//row.unit = service.unit ? service.unit : services.value.find(i => i.id === row.service).unit
|
||||
row.inputPrice = ((service.sellingPriceComposed.total || service.sellingPrice) ? (service.sellingPriceComposed.total || service.sellingPrice) : (services.value.find(i => i.id === row.service).sellingPriceComposed.total || services.value.find(i => i.id === row.service).sellingPrice))
|
||||
row.description = service.description ? service.description : (services.value.find(i => i.id === row.service) ? services.value.find(i => i.id === row.service).description : "")
|
||||
row.costCentre = service.costcentre || services.value.find(i => i.id === row.service)?.costcentre || itemInfo.value.costcentre
|
||||
|
||||
if (['13b UStG', '19 UStG'].includes(itemInfo.value.taxType)) {
|
||||
row.taxPercent = 0
|
||||
@@ -1832,6 +1853,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
console.log("Product Detected")
|
||||
row.unit = product.unit ? product.unit : products.value.find(i => i.id === row.product).unit
|
||||
row.inputPrice = (product.selling_price ? product.selling_price : products.value.find(i => i.id === row.product).selling_price)
|
||||
row.costCentre = row.costCentre || itemInfo.value.costcentre
|
||||
//row.price = Number((row.originalPrice * (1 + itemInfo.value.customSurchargePercentage /100)).toFixed(2))
|
||||
row.description = product.description ? product.description : (products.value.find(i => i.id === row.product) ? products.value.find(i => i.id === row.product).description : "")
|
||||
|
||||
@@ -2512,6 +2534,41 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Kostenstelle:"
|
||||
>
|
||||
<InputGroup>
|
||||
<USelectMenu
|
||||
:items="costcentres"
|
||||
v-model="itemInfo.costcentre"
|
||||
value-key="id"
|
||||
label-key="name"
|
||||
:search-input="{ placeholder: 'Suche...' }"
|
||||
:filter-fields="['name', 'number']"
|
||||
class="w-full"
|
||||
>
|
||||
<template #default>
|
||||
{{ getCostCentreName(itemInfo.costcentre) }}
|
||||
</template>
|
||||
<template #item="{ item: costcentre }">
|
||||
{{ [costcentre.number, costcentre.name].filter(Boolean).join(" - ") }}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="error"
|
||||
v-if="itemInfo.costcentre"
|
||||
icon="i-heroicons-x-mark"
|
||||
@click="itemInfo.costcentre = null"
|
||||
/>
|
||||
<EntityModalButtons
|
||||
type="costcentres"
|
||||
:id="itemInfo.costcentre"
|
||||
@return-data="(data) => itemInfo.costcentre = data.id"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Vertrag:"
|
||||
@@ -2728,6 +2785,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
<th class="pl-2">Menge</th>
|
||||
<th class="pl-2">Einheit</th>
|
||||
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Preis</th>
|
||||
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">EK</th>
|
||||
<!-- <th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Steuer</th>
|
||||
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Rabatt</th>-->
|
||||
<th class="pl-2"></th>
|
||||
@@ -2752,13 +2810,13 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
</td>
|
||||
<td
|
||||
v-if="row.mode === 'pagebreak'"
|
||||
colspan="7"
|
||||
colspan="8"
|
||||
>
|
||||
<USeparator/>
|
||||
</td>
|
||||
<td
|
||||
v-if="row.mode === 'text'"
|
||||
colspan="7"
|
||||
colspan="8"
|
||||
>
|
||||
<!-- <UInput
|
||||
v-model="row.text"
|
||||
@@ -3004,6 +3062,24 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
</template>
|
||||
</UInput>
|
||||
</td>
|
||||
<td
|
||||
class="w-full"
|
||||
v-if="!['pagebreak','title','text'].includes(row.mode) && !deliveryNoteLikeDocumentTypes.includes(itemInfo.type)"
|
||||
>
|
||||
<UInput
|
||||
v-if="row.mode === 'free'"
|
||||
v-model="row.purchasePrice"
|
||||
:disabled="itemInfo.type === 'cancellationInvoices'"
|
||||
type="number"
|
||||
step="0.001"
|
||||
class="w-28"
|
||||
>
|
||||
<template #leading>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">EUR</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
<!-- <td
|
||||
class="w-40"
|
||||
v-if="!['pagebreak','title','text'].includes(row.mode)&& !deliveryNoteLikeDocumentTypes.includes(itemInfo.type)"
|
||||
@@ -3101,9 +3177,32 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
</UFormField>
|
||||
</InputGroup>
|
||||
|
||||
<UFormField
|
||||
label="Kostenstelle:"
|
||||
class="mt-3"
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="row.costCentre"
|
||||
:items="costcentres"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
:search-input="{ placeholder: 'Suche...' }"
|
||||
:filter-fields="['name', 'number']"
|
||||
class="w-full"
|
||||
>
|
||||
<template #default>
|
||||
<span class="truncate">{{ getCostCentreName(row.costCentre) }}</span>
|
||||
</template>
|
||||
<template #item="{ item: costcentre }">
|
||||
{{ [costcentre.number, costcentre.name].filter(Boolean).join(" - ") }}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Einzelpreis:"
|
||||
v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)"
|
||||
class="mt-3"
|
||||
>
|
||||
<UInput
|
||||
v-model="row.inputPrice"
|
||||
@@ -3324,7 +3423,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
</td>
|
||||
<td
|
||||
v-if="row.mode === 'title'"
|
||||
colspan="6"
|
||||
colspan="7"
|
||||
>
|
||||
<UInput
|
||||
:disabled="itemInfo.type === 'cancellationInvoices'"
|
||||
|
||||
873
frontend/pages/email/index.vue
Normal file
873
frontend/pages/email/index.vue
Normal file
@@ -0,0 +1,873 @@
|
||||
<script setup lang="ts">
|
||||
import { format, isToday, isYesterday } from "date-fns"
|
||||
import { de as deLocale } from "date-fns/locale"
|
||||
|
||||
type EmailAccount = {
|
||||
id: string
|
||||
email: string
|
||||
imapHost?: string | null
|
||||
hasPassword?: boolean
|
||||
}
|
||||
|
||||
type EmailMailbox = {
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
delimiter?: string | null
|
||||
specialUse?: string | null
|
||||
unseen?: number
|
||||
}
|
||||
|
||||
type EmailMailboxNode = {
|
||||
mailbox: EmailMailbox
|
||||
children: EmailMailboxNode[]
|
||||
}
|
||||
|
||||
type EmailAddress = {
|
||||
name?: string | null
|
||||
address?: string | null
|
||||
}
|
||||
|
||||
type EmailMessage = {
|
||||
id: string
|
||||
accountId: string
|
||||
mailboxPath: string
|
||||
subject?: string | null
|
||||
from?: EmailAddress[]
|
||||
to?: EmailAddress[]
|
||||
cc?: EmailAddress[]
|
||||
preview?: string | null
|
||||
seen?: boolean
|
||||
flagged?: boolean
|
||||
hasAttachments?: boolean
|
||||
receivedAt?: string | null
|
||||
sentAt?: string | null
|
||||
body?: {
|
||||
text?: string | null
|
||||
html?: string | null
|
||||
} | null
|
||||
attachments?: Array<{
|
||||
id: string
|
||||
filename?: string | null
|
||||
contentType?: string | null
|
||||
size?: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
const { $api } = useNuxtApp()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const toast = useToast()
|
||||
|
||||
const accounts = ref<EmailAccount[]>([])
|
||||
const mailboxes = ref<EmailMailbox[]>([])
|
||||
const messages = ref<EmailMessage[]>([])
|
||||
const selectedAccountId = ref("")
|
||||
const selectedMailboxPath = ref("INBOX")
|
||||
const selectedMessage = ref<EmailMessage | null>(null)
|
||||
const search = ref("")
|
||||
const loadingAccounts = ref(true)
|
||||
const loadingMailboxes = ref(false)
|
||||
const loadingMessages = ref(false)
|
||||
const loadingMessage = ref(false)
|
||||
const syncing = ref(false)
|
||||
const expandedMailboxPaths = ref<string[]>([])
|
||||
const syncedMailboxPaths = ref<string[]>([])
|
||||
const actionLoading = ref("")
|
||||
const moveTargetMailboxPath = ref("")
|
||||
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((account) => account.id === selectedAccountId.value) || null
|
||||
)
|
||||
|
||||
const selectedMailbox = computed(() =>
|
||||
mailboxes.value.find((mailbox) => mailbox.path === selectedMailboxPath.value) || null
|
||||
)
|
||||
|
||||
const mailboxPriority = (mailbox: EmailMailbox) => {
|
||||
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return 0
|
||||
if (mailbox.specialUse === "\\Sent") return 1
|
||||
if (mailbox.specialUse === "\\Drafts") return 2
|
||||
if (mailbox.specialUse === "\\Archive") return 3
|
||||
if (mailbox.specialUse === "\\Junk") return 4
|
||||
if (mailbox.specialUse === "\\Trash") return 5
|
||||
return 9
|
||||
}
|
||||
|
||||
const mailboxDelimiter = (mailbox: EmailMailbox) => {
|
||||
if (mailbox.delimiter) return mailbox.delimiter
|
||||
if (mailbox.path.includes("/")) return "/"
|
||||
if (mailbox.path.includes(".")) return "."
|
||||
return "/"
|
||||
}
|
||||
|
||||
const parentMailboxPath = (mailbox: EmailMailbox, mailboxPaths: Set<string>) => {
|
||||
const delimiter = mailboxDelimiter(mailbox)
|
||||
const parts = mailbox.path.split(delimiter)
|
||||
|
||||
while (parts.length > 1) {
|
||||
parts.pop()
|
||||
const candidate = parts.join(delimiter)
|
||||
if (mailboxPaths.has(candidate)) return candidate
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const sortMailboxNodes = (nodes: EmailMailboxNode[]) => {
|
||||
nodes.sort((first, second) =>
|
||||
mailboxPriority(first.mailbox) - mailboxPriority(second.mailbox)
|
||||
|| mailboxLabel(first.mailbox).localeCompare(mailboxLabel(second.mailbox))
|
||||
)
|
||||
nodes.forEach((node) => sortMailboxNodes(node.children))
|
||||
return nodes
|
||||
}
|
||||
|
||||
const mailboxTree = computed(() => {
|
||||
const mailboxPaths = new Set(mailboxes.value.map((mailbox) => mailbox.path))
|
||||
const nodes = new Map<string, EmailMailboxNode>()
|
||||
const roots: EmailMailboxNode[] = []
|
||||
|
||||
mailboxes.value.forEach((mailbox) => {
|
||||
nodes.set(mailbox.path, { mailbox, children: [] })
|
||||
})
|
||||
|
||||
mailboxes.value.forEach((mailbox) => {
|
||||
const node = nodes.get(mailbox.path)
|
||||
if (!node) return
|
||||
|
||||
const parentPath = parentMailboxPath(mailbox, mailboxPaths)
|
||||
const parent = parentPath ? nodes.get(parentPath) : null
|
||||
|
||||
if (parent) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return sortMailboxNodes(roots)
|
||||
})
|
||||
|
||||
const mailboxRows = computed(() => {
|
||||
const rows: Array<{ mailbox: EmailMailbox; depth: number; hasChildren: boolean }> = []
|
||||
const append = (node: EmailMailboxNode, depth: number) => {
|
||||
const expanded = expandedMailboxPaths.value.includes(node.mailbox.path)
|
||||
rows.push({ mailbox: node.mailbox, depth, hasChildren: node.children.length > 0 })
|
||||
if (expanded) {
|
||||
node.children.forEach((child) => append(child, depth + 1))
|
||||
}
|
||||
}
|
||||
|
||||
mailboxTree.value.forEach((node) => append(node, 0))
|
||||
return rows
|
||||
})
|
||||
|
||||
const filteredMessages = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase()
|
||||
if (!needle) return messages.value
|
||||
|
||||
return messages.value.filter((message) => [
|
||||
message.subject,
|
||||
message.preview,
|
||||
formatAddressList(message.from),
|
||||
formatAddressList(message.to),
|
||||
].some((value) => String(value || "").toLowerCase().includes(needle)))
|
||||
})
|
||||
|
||||
const moveMailboxOptions = computed(() =>
|
||||
mailboxes.value
|
||||
.filter((mailbox) => mailbox.path !== selectedMessage.value?.mailboxPath)
|
||||
.map((mailbox) => ({
|
||||
label: mailboxLabel(mailbox),
|
||||
value: mailbox.path,
|
||||
}))
|
||||
)
|
||||
|
||||
const mailboxIcon = (mailbox: EmailMailbox) => {
|
||||
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return "i-heroicons-inbox"
|
||||
if (mailbox.specialUse === "\\Sent") return "i-heroicons-paper-airplane"
|
||||
if (mailbox.specialUse === "\\Drafts") return "i-heroicons-document"
|
||||
if (mailbox.specialUse === "\\Archive") return "i-heroicons-archive-box"
|
||||
if (mailbox.specialUse === "\\Junk") return "i-heroicons-no-symbol"
|
||||
if (mailbox.specialUse === "\\Trash") return "i-heroicons-trash"
|
||||
return "i-heroicons-folder"
|
||||
}
|
||||
|
||||
const mailboxLabel = (mailbox: EmailMailbox) => {
|
||||
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return "Posteingang"
|
||||
if (mailbox.specialUse === "\\Sent") return "Gesendet"
|
||||
if (mailbox.specialUse === "\\Drafts") return "Entwürfe"
|
||||
if (mailbox.specialUse === "\\Archive") return "Archiv"
|
||||
if (mailbox.specialUse === "\\Junk") return "Spam"
|
||||
if (mailbox.specialUse === "\\Trash") return "Papierkorb"
|
||||
return mailbox.name || mailbox.path
|
||||
}
|
||||
|
||||
const isMailboxExpanded = (mailbox: EmailMailbox) => expandedMailboxPaths.value.includes(mailbox.path)
|
||||
|
||||
const expandMailboxPath = (path: string) => {
|
||||
if (!expandedMailboxPaths.value.includes(path)) {
|
||||
expandedMailboxPaths.value = [...expandedMailboxPaths.value, path]
|
||||
}
|
||||
}
|
||||
|
||||
const collapseMailboxPath = (path: string) => {
|
||||
expandedMailboxPaths.value = expandedMailboxPaths.value.filter((item) => item !== path)
|
||||
}
|
||||
|
||||
const toggleMailboxExpanded = (mailbox: EmailMailbox) => {
|
||||
if (isMailboxExpanded(mailbox)) {
|
||||
collapseMailboxPath(mailbox.path)
|
||||
} else {
|
||||
expandMailboxPath(mailbox.path)
|
||||
}
|
||||
}
|
||||
|
||||
const expandMailboxAncestors = (path: string) => {
|
||||
const mailbox = mailboxes.value.find((item) => item.path === path)
|
||||
if (!mailbox) return
|
||||
|
||||
const delimiter = mailboxDelimiter(mailbox)
|
||||
const parts = path.split(delimiter)
|
||||
|
||||
while (parts.length > 1) {
|
||||
parts.pop()
|
||||
expandMailboxPath(parts.join(delimiter))
|
||||
}
|
||||
}
|
||||
|
||||
const resetExpandedMailboxes = () => {
|
||||
const roots = mailboxTree.value.map((node) => node.mailbox.path)
|
||||
expandedMailboxPaths.value = Array.from(new Set([
|
||||
...roots,
|
||||
...expandedMailboxPaths.value.filter((path) => mailboxes.value.some((mailbox) => mailbox.path === path)),
|
||||
]))
|
||||
}
|
||||
|
||||
const formatAddress = (address?: EmailAddress | null) => {
|
||||
if (!address) return "Unbekannt"
|
||||
return address.name || address.address || "Unbekannt"
|
||||
}
|
||||
|
||||
const formatAddressList = (addresses?: EmailAddress[] | null) => {
|
||||
return (addresses || []).map(formatAddress).filter(Boolean).join(", ")
|
||||
}
|
||||
|
||||
const formatMessageDate = (value?: string | null) => {
|
||||
if (!value) return ""
|
||||
const date = new Date(value)
|
||||
if (isToday(date)) return format(date, "HH:mm")
|
||||
if (isYesterday(date)) return "Gestern"
|
||||
return format(date, "dd. MMM", { locale: deLocale })
|
||||
}
|
||||
|
||||
const formatDetailDate = (value?: string | null) => {
|
||||
if (!value) return ""
|
||||
return format(new Date(value), "dd. MMMM yyyy, HH:mm", { locale: deLocale })
|
||||
}
|
||||
|
||||
const formatAttachmentSize = (size?: number | null) => {
|
||||
if (!size) return ""
|
||||
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const iframeContent = computed(() => {
|
||||
const html = selectedMessage.value?.body?.html
|
||||
if (html) {
|
||||
return `<!doctype html><html><head><base target="_blank"><style>body{font-family:Arial,sans-serif;font-size:14px;line-height:1.5;color:#111827;margin:0;padding:0}img{max-width:100%;height:auto}table{max-width:100%}</style></head><body>${html}</body></html>`
|
||||
}
|
||||
|
||||
const text = selectedMessage.value?.body?.text || selectedMessage.value?.preview || ""
|
||||
return `<!doctype html><html><head><style>body{font-family:Arial,sans-serif;font-size:14px;line-height:1.5;color:#111827;margin:0;padding:0;white-space:pre-wrap}</style></head><body>${escapeHtml(text)}</body></html>`
|
||||
})
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
loadingAccounts.value = true
|
||||
try {
|
||||
accounts.value = await $api("/api/email/accounts")
|
||||
selectedAccountId.value = accounts.value[0]?.id || ""
|
||||
if (selectedAccountId.value) {
|
||||
await loadMailboxes()
|
||||
}
|
||||
} finally {
|
||||
loadingAccounts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMailboxes() {
|
||||
if (!selectedAccountId.value) return
|
||||
|
||||
const previousMailboxPath = selectedMailboxPath.value
|
||||
loadingMailboxes.value = true
|
||||
selectedMessage.value = null
|
||||
|
||||
try {
|
||||
mailboxes.value = await $api(`/api/email/accounts/${selectedAccountId.value}/mailboxes`)
|
||||
resetExpandedMailboxes()
|
||||
const inbox = mailboxes.value.find((mailbox) => mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX")
|
||||
const previousMailbox = mailboxes.value.find((mailbox) => mailbox.path === previousMailboxPath)
|
||||
selectedMailboxPath.value = previousMailbox?.path || inbox?.path || mailboxes.value[0]?.path || "INBOX"
|
||||
expandMailboxAncestors(selectedMailboxPath.value)
|
||||
await loadMessages()
|
||||
} finally {
|
||||
loadingMailboxes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
|
||||
if (!selectedAccountId.value || !selectedMailboxPath.value) return
|
||||
|
||||
loadingMessages.value = true
|
||||
selectedMessage.value = null
|
||||
|
||||
try {
|
||||
messages.value = await fetchMessages(selectedMailboxPath.value)
|
||||
|
||||
if (!messages.value.length && options.syncIfEmpty && !syncedMailboxPaths.value.includes(selectedMailboxPath.value)) {
|
||||
await syncSelectedMailbox({ silent: true, reloadMailboxes: false })
|
||||
syncedMailboxPaths.value = [...syncedMailboxPaths.value, selectedMailboxPath.value]
|
||||
messages.value = await fetchMessages(selectedMailboxPath.value)
|
||||
}
|
||||
|
||||
if (messages.value.length) {
|
||||
await selectMessage(messages.value[0])
|
||||
}
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages(mailboxPath: string) {
|
||||
return await $api(`/api/email/accounts/${selectedAccountId.value}/messages`, {
|
||||
query: {
|
||||
mailbox: mailboxPath,
|
||||
limit: 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function selectMailbox(mailbox: EmailMailbox) {
|
||||
selectedMailboxPath.value = mailbox.path
|
||||
expandMailboxAncestors(mailbox.path)
|
||||
await loadMessages({ syncIfEmpty: true })
|
||||
}
|
||||
|
||||
async function selectMessage(message: EmailMessage) {
|
||||
loadingMessage.value = true
|
||||
try {
|
||||
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
||||
moveTargetMailboxPath.value = ""
|
||||
if (!message.seen) {
|
||||
await setMessageSeen(message.id, true)
|
||||
}
|
||||
} finally {
|
||||
loadingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeMessageFromCurrentList(messageId: string) {
|
||||
const currentIndex = messages.value.findIndex((message) => message.id === messageId)
|
||||
messages.value = messages.value.filter((message) => message.id !== messageId)
|
||||
|
||||
const nextMessage = messages.value[currentIndex] || messages.value[currentIndex - 1] || null
|
||||
selectedMessage.value = null
|
||||
|
||||
if (nextMessage) {
|
||||
selectMessage(nextMessage)
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSelectedMessage() {
|
||||
if (!selectedMessage.value) return
|
||||
|
||||
const messageId = selectedMessage.value.id
|
||||
actionLoading.value = "archive"
|
||||
|
||||
try {
|
||||
await $api(`/api/email/messages/${messageId}/archive`, { method: "POST" })
|
||||
removeMessageFromCurrentList(messageId)
|
||||
toast.add({ title: "E-Mail archiviert", color: "success" })
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Archivieren fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht archiviert werden.",
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
actionLoading.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedMessage() {
|
||||
if (!selectedMessage.value) return
|
||||
|
||||
const messageId = selectedMessage.value.id
|
||||
actionLoading.value = "delete"
|
||||
|
||||
try {
|
||||
await $api(`/api/email/messages/${messageId}`, { method: "DELETE" })
|
||||
removeMessageFromCurrentList(messageId)
|
||||
toast.add({ title: "E-Mail gelöscht", color: "success" })
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Löschen fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht gelöscht werden.",
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
actionLoading.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
async function moveSelectedMessage() {
|
||||
if (!selectedMessage.value || !moveTargetMailboxPath.value) return
|
||||
|
||||
const messageId = selectedMessage.value.id
|
||||
actionLoading.value = "move"
|
||||
|
||||
try {
|
||||
await $api(`/api/email/messages/${messageId}/move`, {
|
||||
method: "POST",
|
||||
body: { mailbox: moveTargetMailboxPath.value },
|
||||
})
|
||||
removeMessageFromCurrentList(messageId)
|
||||
toast.add({ title: "E-Mail verschoben", color: "success" })
|
||||
moveTargetMailboxPath.value = ""
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Verschieben fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht verschoben werden.",
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
actionLoading.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAttachment(attachment: NonNullable<EmailMessage["attachments"]>[number]) {
|
||||
actionLoading.value = `attachment-${attachment.id}`
|
||||
try {
|
||||
const apiBase = String(runtimeConfig.public.apiBase || "").replace(/\/$/, "")
|
||||
const path = `/api/email/attachments/${attachment.id}/download`
|
||||
const downloadUrl = new URL(apiBase ? `${apiBase}${path}` : path, window.location.origin)
|
||||
const token = useCookie<string | null>("token", { path: "/" }).value
|
||||
|
||||
if (token) {
|
||||
downloadUrl.searchParams.set("downloadToken", token)
|
||||
}
|
||||
|
||||
window.location.assign(downloadUrl.toString())
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Download fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Der Anhang konnte nicht geladen werden.",
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
actionLoading.value = ""
|
||||
}, 750)
|
||||
}
|
||||
}
|
||||
|
||||
async function setMessageSeen(messageId: string, seen: boolean) {
|
||||
const previousMessage = messages.value.find((message) => message.id === messageId)
|
||||
|
||||
const res = await $api(`/api/email/messages/${messageId}/read`, {
|
||||
method: "POST",
|
||||
body: { seen },
|
||||
})
|
||||
|
||||
messages.value = messages.value.map((message) =>
|
||||
message.id === messageId ? { ...message, seen } : message
|
||||
)
|
||||
|
||||
if (selectedMessage.value?.id === messageId) {
|
||||
selectedMessage.value = {
|
||||
...selectedMessage.value,
|
||||
...(res.message || {}),
|
||||
seen,
|
||||
}
|
||||
}
|
||||
|
||||
if (previousMessage && previousMessage.seen !== seen) {
|
||||
mailboxes.value = mailboxes.value.map((mailbox) => {
|
||||
if (mailbox.path !== previousMessage.mailboxPath) return mailbox
|
||||
const delta = seen ? -1 : 1
|
||||
return {
|
||||
...mailbox,
|
||||
unseen: Math.max(0, Number(mailbox.unseen || 0) + delta),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAccount() {
|
||||
if (!selectedAccountId.value) return
|
||||
|
||||
await syncSelectedMailbox({ silent: false, reloadMailboxes: true })
|
||||
}
|
||||
|
||||
async function syncSelectedMailbox(options: { silent: boolean; reloadMailboxes: boolean }) {
|
||||
syncing.value = true
|
||||
try {
|
||||
const res = await $api(`/api/email/accounts/${selectedAccountId.value}/sync`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
mailbox: selectedMailboxPath.value,
|
||||
limit: 100,
|
||||
},
|
||||
})
|
||||
|
||||
if (!options.silent) {
|
||||
toast.add({
|
||||
title: "E-Mails synchronisiert",
|
||||
description: `${res.synced?.[0]?.fetched || 0} neue Nachrichten geladen`,
|
||||
color: "success",
|
||||
})
|
||||
}
|
||||
|
||||
if (options.reloadMailboxes) {
|
||||
await loadMailboxes()
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!options.silent) {
|
||||
toast.add({
|
||||
title: "Synchronisation fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Das Postfach konnte nicht synchronisiert werden.",
|
||||
color: "error",
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedAccountId, async (next, previous) => {
|
||||
if (next && previous && next !== previous) {
|
||||
await loadMailboxes()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadAccounts)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPage>
|
||||
<UDashboardNavbar>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-envelope" class="size-5 text-primary" />
|
||||
<span class="text-lg font-semibold">E-Mail</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<UButton
|
||||
icon="i-heroicons-pencil-square"
|
||||
size="sm"
|
||||
@click="navigateTo('/email/new')"
|
||||
>
|
||||
Neue E-Mail
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardToolbar>
|
||||
<template #left>
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
size="sm"
|
||||
class="w-72"
|
||||
placeholder="E-Mails durchsuchen"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<UButton
|
||||
icon="i-heroicons-cog-6-tooth"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="navigateTo('/settings/emailaccounts')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="syncing"
|
||||
:disabled="!selectedAccountId"
|
||||
@click="syncAccount"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardToolbar>
|
||||
|
||||
<div class="flex h-[calc(100vh-150px)] overflow-hidden">
|
||||
<aside class="w-72 shrink-0 border-r border-(--ui-border) bg-(--ui-bg) overflow-y-auto">
|
||||
<div class="border-b border-(--ui-border) p-3">
|
||||
<USkeleton v-if="loadingAccounts" class="h-9" />
|
||||
<USelectMenu
|
||||
v-else-if="accounts.length"
|
||||
v-model="selectedAccountId"
|
||||
:items="accounts"
|
||||
label-key="email"
|
||||
value-key="id"
|
||||
class="w-full"
|
||||
/>
|
||||
<UButton
|
||||
v-else
|
||||
icon="i-heroicons-plus"
|
||||
block
|
||||
@click="navigateTo('/settings/emailaccounts/create')"
|
||||
>
|
||||
E-Mail Konto
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMailboxes" class="space-y-2 p-3">
|
||||
<USkeleton v-for="i in 6" :key="i" class="h-9" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!accounts.length" class="p-4 text-sm text-dimmed">
|
||||
Lege zuerst ein E-Mail Konto an.
|
||||
</div>
|
||||
|
||||
<nav v-else class="p-2">
|
||||
<button
|
||||
v-for="row in mailboxRows"
|
||||
:key="row.mailbox.id"
|
||||
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
||||
:class="selectedMailboxPath === row.mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)'"
|
||||
:style="{ paddingLeft: `${12 + row.depth * 18}px` }"
|
||||
@click="selectMailbox(row.mailbox)"
|
||||
>
|
||||
<UIcon
|
||||
v-if="row.hasChildren"
|
||||
:name="isMailboxExpanded(row.mailbox) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
||||
class="size-3 shrink-0 text-dimmed"
|
||||
@click.stop="toggleMailboxExpanded(row.mailbox)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<UIcon :name="mailboxIcon(row.mailbox)" class="size-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ mailboxLabel(row.mailbox) }}</span>
|
||||
<UBadge
|
||||
v-if="row.mailbox.unseen"
|
||||
size="xs"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
>
|
||||
{{ row.mailbox.unseen }}
|
||||
</UBadge>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section class="w-[390px] shrink-0 border-r border-(--ui-border) bg-(--ui-bg) overflow-y-auto">
|
||||
<div class="flex h-12 items-center justify-between border-b border-(--ui-border) px-4">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ selectedMailbox ? mailboxLabel(selectedMailbox) : 'Postfach' }}
|
||||
</p>
|
||||
<p class="text-xs text-dimmed">
|
||||
{{ filteredMessages.length }} Nachrichten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMessages" class="space-y-2 p-3">
|
||||
<USkeleton v-for="i in 8" :key="i" class="h-20" />
|
||||
</div>
|
||||
|
||||
<TableEmptyState
|
||||
v-else-if="!filteredMessages.length"
|
||||
label="Keine E-Mails anzuzeigen"
|
||||
/>
|
||||
|
||||
<div v-else class="divide-y divide-(--ui-border)">
|
||||
<button
|
||||
v-for="message in filteredMessages"
|
||||
:key="message.id"
|
||||
class="block w-full border-l-2 px-4 py-3 text-left transition-colors"
|
||||
:class="selectedMessage?.id === message.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-(--ui-bg-muted)'"
|
||||
@click="selectMessage(message)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<p class="min-w-0 truncate text-sm" :class="message.seen ? 'font-medium' : 'font-semibold'">
|
||||
{{ formatAddress(message.from?.[0]) }}
|
||||
</p>
|
||||
<span class="shrink-0 text-xs text-dimmed">
|
||||
{{ formatMessageDate(message.receivedAt || message.sentAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-sm" :class="message.seen ? 'text-highlighted' : 'font-semibold text-highlighted'">
|
||||
{{ message.subject || '(kein Betreff)' }}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-dimmed">
|
||||
<UIcon
|
||||
v-if="message.hasAttachments"
|
||||
name="i-heroicons-paper-clip"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<p class="min-w-0 flex-1 truncate">
|
||||
{{ message.preview || 'Keine Vorschau' }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)">
|
||||
<div v-if="loadingMessage" class="space-y-4 p-6">
|
||||
<USkeleton class="h-8 w-2/3" />
|
||||
<USkeleton class="h-5 w-1/3" />
|
||||
<USkeleton class="h-80" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedMessage" class="flex min-h-full flex-col">
|
||||
<div class="border-b border-(--ui-border) px-6 py-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-xl font-semibold text-highlighted">
|
||||
{{ selectedMessage.subject || '(kein Betreff)' }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-dimmed">
|
||||
{{ formatDetailDate(selectedMessage.receivedAt || selectedMessage.sentAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<USelectMenu
|
||||
v-model="moveTargetMailboxPath"
|
||||
:items="moveMailboxOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
placeholder="Verschieben nach"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-folder-arrow-down"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="actionLoading === 'move'"
|
||||
:disabled="!moveTargetMailboxPath"
|
||||
@click="moveSelectedMessage"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-archive-box-arrow-down"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:loading="actionLoading === 'archive'"
|
||||
@click="archiveSelectedMessage"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-trash"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:loading="actionLoading === 'delete'"
|
||||
@click="deleteSelectedMessage"
|
||||
/>
|
||||
<UButton
|
||||
:icon="selectedMessage.seen ? 'i-heroicons-envelope' : 'i-heroicons-envelope-open'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="setMessageSeen(selectedMessage.id, !selectedMessage.seen)"
|
||||
>
|
||||
{{ selectedMessage.seen ? 'Ungelesen' : 'Gelesen' }}
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-uturn-left"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
@click="navigateTo(`/email/new?to=${encodeURIComponent(formatAddressList(selectedMessage.from))}&subject=${encodeURIComponent(`Re: ${selectedMessage.subject || ''}`)}`)"
|
||||
>
|
||||
Antworten
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-uturn-right"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="navigateTo(`/email/new?subject=${encodeURIComponent(`Fw: ${selectedMessage.subject || ''}`)}`)"
|
||||
>
|
||||
Weiterleiten
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-1 text-sm">
|
||||
<p>
|
||||
<span class="text-dimmed">Von:</span>
|
||||
<span class="ml-2">{{ formatAddressList(selectedMessage.from) || 'Unbekannt' }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-dimmed">An:</span>
|
||||
<span class="ml-2">{{ formatAddressList(selectedMessage.to) || selectedAccount?.email }}</span>
|
||||
</p>
|
||||
<p v-if="selectedMessage.cc?.length">
|
||||
<span class="text-dimmed">Kopie:</span>
|
||||
<span class="ml-2">{{ formatAddressList(selectedMessage.cc) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMessage.attachments?.length" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="attachment in selectedMessage.attachments"
|
||||
:key="attachment.id"
|
||||
class="flex items-center gap-2 rounded-md border border-(--ui-border) px-3 py-2 text-left text-sm hover:bg-(--ui-bg-muted)"
|
||||
@click="downloadAttachment(attachment)"
|
||||
>
|
||||
<UIcon name="i-heroicons-paper-clip" class="size-4 text-dimmed" />
|
||||
<span>{{ attachment.filename || 'Anhang' }}</span>
|
||||
<span class="text-xs text-dimmed">{{ formatAttachmentSize(attachment.size) }}</span>
|
||||
<UIcon
|
||||
:name="actionLoading === `attachment-${attachment.id}` ? 'i-heroicons-arrow-path' : 'i-heroicons-arrow-down-tray'"
|
||||
class="size-4 text-dimmed"
|
||||
:class="actionLoading === `attachment-${attachment.id}` ? 'animate-spin' : ''"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6">
|
||||
<iframe
|
||||
title="E-Mail Inhalt"
|
||||
class="h-[calc(100vh-360px)] min-h-[420px] w-full"
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox"
|
||||
:srcdoc="iframeContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex h-full items-center justify-center p-6 text-center">
|
||||
<div>
|
||||
<UIcon name="i-heroicons-envelope-open" class="mx-auto mb-3 size-10 text-dimmed" />
|
||||
<p class="font-medium">Keine E-Mail ausgewählt</p>
|
||||
<p class="mt-1 text-sm text-dimmed">Wähle links eine Nachricht aus oder synchronisiere dein Postfach.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</UPage>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user