Compare commits
227 Commits
4e49dd18a1
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 033e74adda | |||
| ccc66ebd0f | |||
| c660f62120 | |||
| ad74825781 | |||
| f1e0f36cca | |||
| 526ad966c4 | |||
| 99501fb924 | |||
| 2fdc89565c | |||
| 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 | |||
| 4b85ea3d2d | |||
| 8bed6e2984 | |||
| 9c1d3bc04c | |||
| 8df587f9e2 | |||
| 3796bc2953 | |||
| 2278dfa714 | |||
| 1a5c69fcfb | |||
| a671ae392d | |||
| 4c58d175a0 | |||
| bc655f0e06 | |||
| 22bcf01fa8 | |||
| bf8a3386d7 | |||
| d182231448 | |||
| 0a32ae77cd | |||
| 98c95483d8 | |||
| bcde1da84f | |||
| 6157d7e27d | |||
| 0cfa6a691b | |||
| 14470da7dc | |||
| 85ac33c334 | |||
| 9e38a488c8 | |||
| ed6283b9e1 | |||
| 2d2e8552f0 | |||
| 25ed99b356 | |||
| 6cc7dc87ad | |||
| 697abc99fa | |||
| bace26c084 | |||
| 274f3d5795 | |||
| 168d2fce6e | |||
| 6dcd8b1863 | |||
| 81ce9d263d | |||
| 6455be81bd | |||
| 9cde630562 | |||
| 48d101e139 | |||
| 167e9a40c3 | |||
| f9d3f10eae | |||
| 6d9bceb63f | |||
| e29e84898b | |||
| 1ccabbedcd | |||
| 24febf4c95 | |||
| 5fc7cc9604 | |||
| 941f1d819b | |||
| 58c47fa8f7 | |||
| ea392af094 | |||
| 0ac22d346f | |||
| 26ffc4421a | |||
| 7caa37378b | |||
| 227a88b24b | |||
| 0fb469c9b0 | |||
| 5b3445c2dc | |||
| 716de8a503 | |||
| 817d0e814b | |||
| 75d5e2b72d | |||
| 30aaf141c7 | |||
| c7ba7a9cc5 | |||
| 1c68e6b724 | |||
| cc3c405473 | |||
| ff70338b21 | |||
| bb3b842be1 | |||
| 9c6a6a841a | |||
| b7b913035e | |||
| 454e9ee3c9 | |||
| 01846d488b | |||
| 80b2b1d097 | |||
| f5755993b5 | |||
| 0f56102030 | |||
| 60d846baa9 | |||
| a28b910d4d | |||
| 4aeefb2b83 | |||
| 24c09d7891 | |||
| 77eabe7e18 | |||
| e4073e01ad | |||
| 248da3412c | |||
| c93ea4284d | |||
| 7c68ce61f2 | |||
| f6dd37b458 | |||
| bb54a8779e | |||
| 6e14f48770 | |||
| 4d24e3a657 | |||
| f33ccf730a | |||
| 8824b1c9c8 | |||
| 571c24f250 | |||
| b03af21e97 | |||
| b1e102ca5d | |||
| 8b40be7909 | |||
| 655459a46b | |||
| 5fca7792a2 | |||
| 30b6ffcc20 | |||
| 7f66f66cfa | |||
| d0de3cb92e | |||
| c893574cb1 | |||
| eb2dd03ef9 | |||
| b322d0c173 | |||
| 54ae136f0d | |||
| 00e1e88dd9 | |||
| 3984e218db | |||
| d9c3c8d07c | |||
| c6a0d59c29 | |||
| 9592e2b062 | |||
| d522cbb49d | |||
| 8d7bc2e97c | |||
| 44017a768b | |||
| 683d073b6e | |||
| cb939f2197 | |||
| 0e71899c57 | |||
| 6b82f2b629 | |||
| 9ba5f26efc | |||
| 82f2143dd1 |
166
.env.example
Normal file
166
.env.example
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# FEDEO Selfhosting
|
||||||
|
DOMAIN=app.example.com
|
||||||
|
CONTACT_EMAIL=admin@deine-domain.de
|
||||||
|
|
||||||
|
DB_NAME=fedeo
|
||||||
|
DB_USER=fedeo
|
||||||
|
DB_PASSWORD=change-this-db-password
|
||||||
|
DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo
|
||||||
|
|
||||||
|
MINIO_ROOT_USER=fedeo-minio
|
||||||
|
MINIO_ROOT_PASSWORD=change-this-minio-password
|
||||||
|
MINIO_BUCKET=fedeo
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3100
|
||||||
|
FEDEO_RUN_MIGRATIONS=true
|
||||||
|
COOKIE_SECRET=change-this-cookie-secret
|
||||||
|
JWT_SECRET=change-this-jwt-secret
|
||||||
|
ENCRYPTION_KEY=change-this-encryption-key
|
||||||
|
|
||||||
|
MAILER_SMTP_HOST=smtp.example.com
|
||||||
|
MAILER_SMTP_PORT=587
|
||||||
|
MAILER_SMTP_SSL=false
|
||||||
|
MAILER_SMTP_USER=mailer@example.com
|
||||||
|
MAILER_SMTP_PASS=change-this-mail-password
|
||||||
|
MAILER_FROM=FEDEO <no-reply@example.com>
|
||||||
|
|
||||||
|
# Desktop Push per Web Push. Schlüssel können mit
|
||||||
|
# `npx web-push generate-vapid-keys` erzeugt werden.
|
||||||
|
WEB_PUSH_PUBLIC_KEY=replace-this-web-push-public-key
|
||||||
|
WEB_PUSH_PRIVATE_KEY=replace-this-web-push-private-key
|
||||||
|
WEB_PUSH_SUBJECT=mailto:admin@example.com
|
||||||
|
|
||||||
|
S3_ENDPOINT=http://minio:9000
|
||||||
|
S3_REGION=eu-central-1
|
||||||
|
S3_ACCESS_KEY=fedeo-minio
|
||||||
|
S3_SECRET_KEY=change-this-minio-password
|
||||||
|
S3_BUCKET=fedeo
|
||||||
|
|
||||||
|
# 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
|
||||||
|
GOCARDLESS_SECRET_ID=replace-this
|
||||||
|
GOCARDLESS_SECRET_KEY=replace-this
|
||||||
|
|
||||||
|
DOKUBOX_IMAP_HOST=imap.example.com
|
||||||
|
DOKUBOX_IMAP_PORT=993
|
||||||
|
DOKUBOX_IMAP_SECURE=true
|
||||||
|
DOKUBOX_IMAP_USER=dokubox@example.com
|
||||||
|
DOKUBOX_IMAP_PASSWORD=change-this-imap-password
|
||||||
|
|
||||||
|
OPENAI_API_KEY=replace-this
|
||||||
|
STIRLING_API_KEY=replace-this
|
||||||
|
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
||||||
|
|
||||||
|
# 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
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
||||||
|
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
||||||
|
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
||||||
|
FEDEO_BOOTSTRAP_MATRIX=true
|
||||||
|
|
||||||
|
# FEDEO Matrix-Kommunikation
|
||||||
|
#
|
||||||
|
# Diese Werte werden von docker-compose.selfhost.yml für den integrierten
|
||||||
|
# Matrix-Stack gelesen. Für produktive Systeme müssen alle Geheimnisse ersetzt
|
||||||
|
# werden.
|
||||||
|
|
||||||
|
MATRIX_SERVER_NAME=app.example.com
|
||||||
|
|
||||||
|
MATRIX_POSTGRES_DB=synapse
|
||||||
|
MATRIX_POSTGRES_USER=synapse
|
||||||
|
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
|
||||||
|
|
||||||
|
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
|
||||||
|
|
||||||
|
LIVEKIT_KEY=fedeo-livekit
|
||||||
|
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
|
||||||
|
|
||||||
|
# Backend-Integration im Selfhost-Stack
|
||||||
|
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
|
||||||
|
MATRIX_RTC_HOST=app.example.com
|
||||||
|
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
|
||||||
|
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
|
||||||
|
MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret
|
||||||
|
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
|
||||||
|
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
|
||||||
|
|
||||||
|
# Lokale Matrix-Entwicklung
|
||||||
|
MATRIX_DEV_SYNAPSE_PORT=8008
|
||||||
|
MATRIX_DEV_ELEMENT_PORT=8080
|
||||||
|
MATRIX_DEV_RTC_JWT_PORT=8081
|
||||||
|
MATRIX_DEV_LIVEKIT_PORT=7880
|
||||||
|
MATRIX_DEV_LIVEKIT_TCP_PORT=7881
|
||||||
|
MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000
|
||||||
|
MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100
|
||||||
|
MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1
|
||||||
|
MATRIX_DEV_TURN_PORT=3478
|
||||||
|
MATRIX_DEV_TURN_MIN_PORT=49160
|
||||||
|
MATRIX_DEV_TURN_MAX_PORT=49200
|
||||||
|
|
||||||
|
# Lokale Backend-Integration gegen den Matrix-Entwicklungsstack
|
||||||
|
# MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||||
|
# MATRIX_RTC_JWT_URL=http://localhost:8081
|
||||||
|
# MATRIX_LIVEKIT_URL=ws://localhost:7880
|
||||||
|
# MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
|
||||||
|
# NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Build and Push Docker Images
|
name: Build and Push Docker Images
|
||||||
run-name: Build Backend, Frontend & Docs by @${{ github.actor }}
|
run-name: Build Backend, Frontend, Website & Docs by @${{ github.actor }}
|
||||||
|
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
@@ -135,3 +135,35 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-docs.outputs.tags }}
|
tags: ${{ steps.meta-docs.outputs.tags }}
|
||||||
labels: ${{ steps.meta-docs.outputs.labels }}
|
labels: ${{ steps.meta-docs.outputs.labels }}
|
||||||
|
|
||||||
|
build-website:
|
||||||
|
#needs: verify-docs-sync
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_HOST }}
|
||||||
|
username: ${{ env.ACTOR }}
|
||||||
|
password: ${{ vars.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Website
|
||||||
|
id: meta-website
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/website
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
|
- name: Build and push Website
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: ./website
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta-website.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-website.outputs.labels }}
|
||||||
|
|||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
|
||||||
|
# Lokale Runtime-Daten und generierte Konfigurationen
|
||||||
|
matrix/postgres/
|
||||||
|
matrix/synapse/
|
||||||
|
matrix/dev/postgres/
|
||||||
|
matrix/dev/synapse/
|
||||||
141
README.md
141
README.md
@@ -89,13 +89,16 @@ Wenn du MinIO verwendest, setze zusatzlich:
|
|||||||
|
|
||||||
## Deploy-Struktur
|
## Deploy-Struktur
|
||||||
|
|
||||||
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
|
Deploye den Stack in einem eigenen Betriebsverzeichnis. Der Selfhost-Installer lädt dafür nur die benötigten Betriebsdateien und klont nicht das komplette Repository.
|
||||||
|
|
||||||
Beispiel:
|
Beispiel für die manuelle Vorbereitung:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <DEIN-REPO-URL> /opt/fedeo
|
mkdir -p /opt/fedeo/scripts
|
||||||
cd /opt/fedeo
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
|
||||||
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
|
||||||
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
|
||||||
|
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Verzeichnisstruktur sollte dann mindestens so aussehen:
|
Die Verzeichnisstruktur sollte dann mindestens so aussehen:
|
||||||
@@ -104,8 +107,7 @@ Die Verzeichnisstruktur sollte dann mindestens so aussehen:
|
|||||||
/opt/fedeo/
|
/opt/fedeo/
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
.env
|
.env
|
||||||
backend/
|
scripts/
|
||||||
frontend/
|
|
||||||
traefik/
|
traefik/
|
||||||
letsencrypt/
|
letsencrypt/
|
||||||
logs/
|
logs/
|
||||||
@@ -124,13 +126,55 @@ touch /opt/fedeo/traefik/letsencrypt/acme.json
|
|||||||
chmod 600 /opt/fedeo/traefik/letsencrypt/acme.json
|
chmod 600 /opt/fedeo/traefik/letsencrypt/acme.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Als Startpunkt kannst du die Beispielumgebung kopieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Ersetze anschließend alle Platzhalter und passe mindestens `DOMAIN`, `CONTACT_EMAIL`, Datenbank-, Secret-, SMTP- und S3-Werte an. Seafile ist kein Teil des Standard-Stacks; wenn FEDEO später Seafile als File-Backend nutzen soll, zeigst du die Seafile-Variablen auf einen externen Seafile-Dienst.
|
||||||
|
|
||||||
|
Alternativ kannst du die Konfiguration geführt erzeugen lassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/selfhost-setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration direkt per One-Liner vorbereiten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Der schnelle One-Liner mit direktem Stack-Start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Installer prüft Basispakete, installiert Docker auf Wunsch über das offizielle Docker-Installationsscript, lädt nur die Selfhost-Dateien nach `/opt/fedeo` und startet anschließend den geführten Setup-Assistenten.
|
||||||
|
|
||||||
|
Für den schnellen Standardpfad:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/selfhost-setup.sh --simple
|
||||||
|
```
|
||||||
|
|
||||||
|
Für mehr Rückfragen zu SMTP, API-Schlüsseln und optionalen Diensten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/selfhost-setup.sh --advanced
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Assistent erklärt zuerst die Selfhost-Verzeichnisstruktur, schreibt anschließend `.env`, legt persistente Verzeichnisse inklusive `traefik/letsencrypt/acme.json` an und kann den Stack optional direkt starten.
|
||||||
|
|
||||||
## Beispiel `.env`
|
## Beispiel `.env`
|
||||||
|
|
||||||
Diese Datei liegt neben der `docker-compose.yml`:
|
Diese Datei liegt neben der `docker-compose.yml`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DOMAIN=app.example.com
|
DOMAIN=app.example.com
|
||||||
CONTACT_EMAIL=admin@example.com
|
CONTACT_EMAIL=admin@deine-domain.de
|
||||||
|
|
||||||
DB_NAME=fedeo
|
DB_NAME=fedeo
|
||||||
DB_USER=fedeo
|
DB_USER=fedeo
|
||||||
@@ -160,6 +204,12 @@ S3_ACCESS_KEY=fedeo-minio
|
|||||||
S3_SECRET_KEY=change-this-minio-password
|
S3_SECRET_KEY=change-this-minio-password
|
||||||
S3_BUCKET=fedeo
|
S3_BUCKET=fedeo
|
||||||
|
|
||||||
|
FEDEO_FILE_BACKEND=s3
|
||||||
|
SEAFILE_BASE_URL=https://files.example.com
|
||||||
|
SEAFILE_INTERNAL_URL=https://files.example.com
|
||||||
|
SEAFILE_ADMIN_EMAIL=admin@example.com
|
||||||
|
SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
|
||||||
|
|
||||||
M2M_API_KEY=change-this-m2m-key
|
M2M_API_KEY=change-this-m2m-key
|
||||||
API_BASE_URL=https://app.example.com/backend
|
API_BASE_URL=https://app.example.com/backend
|
||||||
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
||||||
@@ -176,11 +226,41 @@ OPENAI_API_KEY=replace-this
|
|||||||
STIRLING_API_KEY=replace-this
|
STIRLING_API_KEY=replace-this
|
||||||
|
|
||||||
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
||||||
|
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
||||||
|
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
||||||
|
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
||||||
|
|
||||||
|
MATRIX_SERVER_NAME=app.example.com
|
||||||
|
MATRIX_POSTGRES_DB=synapse
|
||||||
|
MATRIX_POSTGRES_USER=synapse
|
||||||
|
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
|
||||||
|
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
|
||||||
|
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
|
||||||
|
MATRIX_RTC_HOST=app.example.com
|
||||||
|
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
|
||||||
|
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
|
||||||
|
MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret
|
||||||
|
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
|
||||||
|
LIVEKIT_KEY=fedeo-livekit
|
||||||
|
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
|
||||||
|
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
|
||||||
```
|
```
|
||||||
|
|
||||||
## Vollstandiges Docker Compose mit optionaler S3-MinIO-Option
|
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
|
||||||
|
|
||||||
Hinweis: Der Stack unten startet MinIO standardmassig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
## Docker Compose mit optionalem S3 und Matrix
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -266,8 +346,7 @@ services:
|
|||||||
- internal
|
- internal
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
||||||
context: ./backend
|
|
||||||
container_name: fedeo-backend
|
container_name: fedeo-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -316,13 +395,13 @@ services:
|
|||||||
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
||||||
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
||||||
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
||||||
|
- traefik.docker.network=fedeo_web
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
|
||||||
context: ./frontend
|
|
||||||
container_name: fedeo-frontend
|
container_name: fedeo-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -337,13 +416,16 @@ services:
|
|||||||
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
||||||
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
||||||
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
||||||
|
- traefik.docker.network=fedeo_web
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web:
|
web:
|
||||||
|
name: fedeo_web
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal:
|
internal:
|
||||||
|
name: fedeo_internal
|
||||||
driver: bridge
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -372,16 +454,23 @@ Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit M
|
|||||||
Im Deploy-Verzeichnis:
|
Im Deploy-Verzeichnis:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose build
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Synapse erzeugt `matrix/synapse/homeserver.yaml` beim ersten Start automatisch und aktualisiert die für FEDEO relevanten Werte aus der `.env`. `MATRIX_REGISTRATION_SHARED_SECRET` muss in der `.env` gesetzt und geheim bleiben, weil FEDEO damit Matrix-Nutzer provisioniert.
|
||||||
|
|
||||||
Danach Status prufen:
|
Danach Status prufen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
|
||||||
docker compose logs -f traefik
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml logs -f traefik
|
||||||
docker compose logs -f backend
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn du Migrationen manuell ausführen möchtest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
## Funktionsprufung
|
## Funktionsprufung
|
||||||
@@ -398,17 +487,23 @@ Erwartung:
|
|||||||
- Frontend liefert `200` oder `302`
|
- Frontend liefert `200` oder `302`
|
||||||
- Backend liefert JSON wie `{"status":"ok"}`
|
- Backend liefert JSON wie `{"status":"ok"}`
|
||||||
|
|
||||||
|
Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` anmelden. Die Mandantensperre wird über `locked` gesteuert; `hasActiveLicense` wird nicht mehr für den Selfhost-Zugriff ausgewertet.
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Bei neuen Versionen:
|
Bei neuen Versionen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
|
||||||
docker compose build
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
|
||||||
docker compose up -d
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
|
||||||
|
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
|
||||||
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
|
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden.
|
||||||
|
|
||||||
|
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
|
||||||
|
|
||||||
## Backup-Empfehlung
|
## Backup-Empfehlung
|
||||||
|
|
||||||
@@ -416,6 +511,8 @@ Regelmassig sichern:
|
|||||||
|
|
||||||
- `./postgres`
|
- `./postgres`
|
||||||
- `./minio` falls MinIO lokal genutzt wird
|
- `./minio` falls MinIO lokal genutzt wird
|
||||||
|
- `./matrix/postgres` falls Matrix lokal betrieben wird
|
||||||
|
- `./matrix/synapse` falls Matrix lokal betrieben wird
|
||||||
- `./traefik/letsencrypt/acme.json`
|
- `./traefik/letsencrypt/acme.json`
|
||||||
- deine `.env`
|
- deine `.env`
|
||||||
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
|
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
|
||||||
|
|||||||
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"]
|
||||||
|
}
|
||||||
@@ -12,8 +12,10 @@ RUN apt-get update \
|
|||||||
# Package-Dateien
|
# Package-Dateien
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Dev + Prod Dependencies (für TS-Build nötig)
|
# Dev + Prod Dependencies (für TS-Build nötig).
|
||||||
RUN npm install
|
# Sharp benötigt im Linux-Container native optionale Pakete, auch wenn das Lockfile auf macOS erzeugt wurde.
|
||||||
|
RUN npm install --include=optional \
|
||||||
|
&& npm install --include=optional --os=linux --cpu=x64 sharp
|
||||||
|
|
||||||
# Restlicher Sourcecode
|
# Restlicher Sourcecode
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -24,5 +26,5 @@ RUN npm run build
|
|||||||
# Port freigeben
|
# Port freigeben
|
||||||
EXPOSE 3100
|
EXPOSE 3100
|
||||||
|
|
||||||
# Start der App
|
# Migrationen ausführen und App starten
|
||||||
CMD ["node", "dist/src/index.js"]
|
CMD ["sh", "./docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -14,26 +14,6 @@ CREATE TABLE "m2m_api_keys" (
|
|||||||
CONSTRAINT "m2m_api_keys_key_hash_unique" UNIQUE("key_hash")
|
CONSTRAINT "m2m_api_keys_key_hash_unique" UNIQUE("key_hash")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "staff_time_events" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"tenant_id" bigint NOT NULL,
|
|
||||||
"user_id" uuid NOT NULL,
|
|
||||||
"actor_type" text NOT NULL,
|
|
||||||
"actor_user_id" uuid,
|
|
||||||
"event_time" timestamp with time zone NOT NULL,
|
|
||||||
"event_type" text NOT NULL,
|
|
||||||
"source" text NOT NULL,
|
|
||||||
"invalidates_event_id" uuid,
|
|
||||||
"related_event_id" uuid,
|
|
||||||
"metadata" jsonb,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "time_events_actor_user_check" CHECK (
|
|
||||||
(actor_type = 'system' AND actor_user_id IS NULL)
|
|
||||||
OR
|
|
||||||
(actor_type = 'user' AND actor_user_id IS NOT NULL)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "serialtypes" (
|
CREATE TABLE "serialtypes" (
|
||||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "serialtypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "serialtypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
@@ -89,20 +69,15 @@ CREATE TABLE "wiki_pages" (
|
|||||||
"updated_by" uuid
|
"updated_by" uuid
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
ALTER TABLE "time_events" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
||||||
DROP TABLE "time_events" CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "projects" ALTER COLUMN "active_phase" SET DEFAULT 'Erstkontakt';--> statement-breakpoint
|
ALTER TABLE "projects" ALTER COLUMN "active_phase" SET DEFAULT 'Erstkontakt';--> statement-breakpoint
|
||||||
ALTER TABLE "createddocuments" ADD COLUMN "serialexecution" uuid;--> statement-breakpoint
|
ALTER TABLE "createddocuments" ADD COLUMN "serialexecution" uuid;--> statement-breakpoint
|
||||||
ALTER TABLE "devices" ADD COLUMN "last_seen" timestamp with time zone;--> statement-breakpoint
|
ALTER TABLE "devices" ADD COLUMN "last_seen" timestamp with time zone;--> statement-breakpoint
|
||||||
ALTER TABLE "devices" ADD COLUMN "last_debug_info" jsonb;--> statement-breakpoint
|
ALTER TABLE "devices" ADD COLUMN "last_debug_info" jsonb;--> statement-breakpoint
|
||||||
ALTER TABLE "files" ADD COLUMN "size" bigint;--> statement-breakpoint
|
ALTER TABLE "files" ADD COLUMN "size" bigint;--> statement-breakpoint
|
||||||
|
ALTER TABLE "staff_time_events" ADD COLUMN "related_event_id" uuid;--> statement-breakpoint
|
||||||
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||||
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||||
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_actor_user_id_auth_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_invalidates_event_id_staff_time_events_id_fk" FOREIGN KEY ("invalidates_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_related_event_id_staff_time_events_id_fk" FOREIGN KEY ("related_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_related_event_id_staff_time_events_id_fk" FOREIGN KEY ("related_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
@@ -113,11 +88,8 @@ ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_tenant_id_tenants_id_fk" FOR
|
|||||||
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_parent_id_wiki_pages_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_parent_id_wiki_pages_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
CREATE INDEX "idx_time_events_tenant_user_time" ON "staff_time_events" USING btree ("tenant_id","user_id","event_time");--> statement-breakpoint
|
|
||||||
CREATE INDEX "idx_time_events_created_at" ON "staff_time_events" USING btree ("created_at");--> statement-breakpoint
|
|
||||||
CREATE INDEX "idx_time_events_invalidates" ON "staff_time_events" USING btree ("invalidates_event_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "wiki_pages_tenant_idx" ON "wiki_pages" USING btree ("tenant_id");--> statement-breakpoint
|
CREATE INDEX "wiki_pages_tenant_idx" ON "wiki_pages" USING btree ("tenant_id");--> statement-breakpoint
|
||||||
CREATE INDEX "wiki_pages_parent_idx" ON "wiki_pages" USING btree ("parent_id");--> statement-breakpoint
|
CREATE INDEX "wiki_pages_parent_idx" ON "wiki_pages" USING btree ("parent_id");--> statement-breakpoint
|
||||||
CREATE INDEX "wiki_pages_entity_int_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_id");--> statement-breakpoint
|
CREATE INDEX "wiki_pages_entity_int_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_id");--> statement-breakpoint
|
||||||
CREATE INDEX "wiki_pages_entity_uuid_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_uuid");--> statement-breakpoint
|
CREATE INDEX "wiki_pages_entity_uuid_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_uuid");--> statement-breakpoint
|
||||||
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_serialexecution_serial_executions_id_fk" FOREIGN KEY ("serialexecution") REFERENCES "public"."serial_executions"("id") ON DELETE no action ON UPDATE no action;
|
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_serialexecution_serial_executions_id_fk" FOREIGN KEY ("serialexecution") REFERENCES "public"."serial_executions"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
|||||||
@@ -1,108 +1 @@
|
|||||||
CREATE TABLE "contracttypes" (
|
-- Absichtlich leer: Die Objekte aus dieser generierten Migration existieren bereits in früheren Migrationen.
|
||||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"tenant" bigint NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"paymentType" text,
|
|
||||||
"recurring" boolean DEFAULT false NOT NULL,
|
|
||||||
"billingInterval" text,
|
|
||||||
"archived" boolean DEFAULT false NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone,
|
|
||||||
"updated_by" uuid
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "customerinventoryitems" (
|
|
||||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"tenant" bigint NOT NULL,
|
|
||||||
"customer" bigint NOT NULL,
|
|
||||||
"customerspace" bigint,
|
|
||||||
"customerInventoryId" text NOT NULL,
|
|
||||||
"serialNumber" text,
|
|
||||||
"quantity" bigint DEFAULT 0 NOT NULL,
|
|
||||||
"manufacturer" text,
|
|
||||||
"manufacturerNumber" text,
|
|
||||||
"purchaseDate" date,
|
|
||||||
"purchasePrice" double precision DEFAULT 0,
|
|
||||||
"currentValue" double precision,
|
|
||||||
"product" bigint,
|
|
||||||
"vendor" bigint,
|
|
||||||
"archived" boolean DEFAULT false NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone,
|
|
||||||
"updated_by" uuid
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "customerspaces" (
|
|
||||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"type" text NOT NULL,
|
|
||||||
"tenant" bigint NOT NULL,
|
|
||||||
"customer" bigint NOT NULL,
|
|
||||||
"spaceNumber" text NOT NULL,
|
|
||||||
"parentSpace" bigint,
|
|
||||||
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"archived" boolean DEFAULT false NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone,
|
|
||||||
"updated_by" uuid
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "entitybankaccounts" (
|
|
||||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"tenant" bigint NOT NULL,
|
|
||||||
"iban_encrypted" jsonb NOT NULL,
|
|
||||||
"bic_encrypted" jsonb NOT NULL,
|
|
||||||
"bank_name_encrypted" jsonb NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"updated_at" timestamp with time zone,
|
|
||||||
"updated_by" uuid,
|
|
||||||
"archived" boolean DEFAULT false NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "memberrelations" (
|
|
||||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"tenant" bigint NOT NULL,
|
|
||||||
"type" text NOT NULL,
|
|
||||||
"billingInterval" text NOT NULL,
|
|
||||||
"billingAmount" double precision DEFAULT 0 NOT NULL,
|
|
||||||
"archived" boolean DEFAULT false NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone,
|
|
||||||
"updated_by" uuid
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;--> statement-breakpoint
|
|
||||||
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;--> statement-breakpoint
|
|
||||||
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
|
|
||||||
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;--> statement-breakpoint
|
|
||||||
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;--> statement-breakpoint
|
|
||||||
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
|
|
||||||
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;
|
|
||||||
|
|||||||
8
backend/db/migrations/0034_events_color.sql
Normal file
8
backend/db/migrations/0034_events_color.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE "events" ADD COLUMN "color" text;
|
||||||
|
|
||||||
|
UPDATE "events" AS e
|
||||||
|
SET "color" = COALESCE(t."calendarConfig"->'quickEntry'->>'color', '#2563eb')
|
||||||
|
FROM "tenants" AS t
|
||||||
|
WHERE e."tenant" = t."id"
|
||||||
|
AND e."quick" = true
|
||||||
|
AND e."color" IS NULL;
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
ALTER TABLE "auth_profiles"
|
ALTER TABLE "auth_profiles"
|
||||||
ADD COLUMN "availability_note" text;
|
ADD COLUMN IF NOT EXISTS "availability_note" text;
|
||||||
|
|||||||
53
backend/db/migrations/0037_outgoing_sepa_mandates.sql
Normal file
53
backend/db/migrations/0037_outgoing_sepa_mandates.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
CREATE TABLE "outgoingsepamandates" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "outgoingsepamandates_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"tenant" bigint NOT NULL,
|
||||||
|
"customer" bigint NOT NULL,
|
||||||
|
"bankaccount" bigint NOT NULL,
|
||||||
|
"reference" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'Entwurf' NOT NULL,
|
||||||
|
"mandate_type" text DEFAULT 'CORE' NOT NULL,
|
||||||
|
"sequence_type" text DEFAULT 'RCUR' NOT NULL,
|
||||||
|
"signed_at" timestamp with time zone,
|
||||||
|
"valid_from" timestamp with time zone,
|
||||||
|
"valid_until" timestamp with time zone,
|
||||||
|
"default_mandate" boolean DEFAULT false NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone,
|
||||||
|
"updated_by" uuid
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_bankaccount_entitybankaccounts_id_fk" FOREIGN KEY ("bankaccount") REFERENCES "public"."entitybankaccounts"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "contracts" ADD COLUMN "outgoingsepamandate" bigint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "createddocuments" ADD COLUMN "outgoingsepamandate" bigint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "historyitems" ADD COLUMN "outgoingsepamandate" bigint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"costEstimates":{"prefix":"KS-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"deliveryNotes":{"prefix":"LS-","suffix":"","nextNumber":1000},"packingSlips":{"prefix":"PS-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000},"outgoingsepamandates":{"prefix":"SEPA-","suffix":"","nextNumber":1000}}'::jsonb;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "tenants"
|
||||||
|
SET "numberRanges" = COALESCE("numberRanges", '{}'::jsonb) || jsonb_build_object(
|
||||||
|
'outgoingsepamandates',
|
||||||
|
COALESCE("numberRanges"->'outgoingsepamandates', '{"prefix":"SEPA-","suffix":"","nextNumber":1000}'::jsonb)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "tenants"
|
||||||
|
SET "features" = COALESCE("features", '{}'::jsonb) || jsonb_build_object(
|
||||||
|
'outgoingsepamandates',
|
||||||
|
COALESCE("features"->'outgoingsepamandates', 'true'::jsonb)
|
||||||
|
);
|
||||||
43
backend/db/migrations/0038_communication_rooms.sql
Normal file
43
backend/db/migrations/0038_communication_rooms.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
CREATE TABLE "communication_rooms" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" bigint NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"topic" text,
|
||||||
|
"type" text DEFAULT 'room' NOT NULL,
|
||||||
|
"entity_type" text,
|
||||||
|
"entity_id" bigint,
|
||||||
|
"entity_uuid" uuid,
|
||||||
|
"matrix_room_id" text,
|
||||||
|
"matrix_alias" text,
|
||||||
|
"parent_space_room_id" text,
|
||||||
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone,
|
||||||
|
"created_by" uuid,
|
||||||
|
"updated_by" uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "communication_rooms"
|
||||||
|
ADD CONSTRAINT "communication_rooms_tenant_id_tenants_id_fk"
|
||||||
|
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
|
||||||
|
ON DELETE cascade ON UPDATE no action;
|
||||||
|
|
||||||
|
ALTER TABLE "communication_rooms"
|
||||||
|
ADD CONSTRAINT "communication_rooms_created_by_auth_users_id_fk"
|
||||||
|
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
|
||||||
|
ON DELETE no action ON UPDATE no action;
|
||||||
|
|
||||||
|
ALTER TABLE "communication_rooms"
|
||||||
|
ADD CONSTRAINT "communication_rooms_updated_by_auth_users_id_fk"
|
||||||
|
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
|
||||||
|
ON DELETE no action ON UPDATE no action;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "communication_rooms_tenant_key_idx"
|
||||||
|
ON "communication_rooms" USING btree ("tenant_id", "key");
|
||||||
|
|
||||||
|
CREATE INDEX "communication_rooms_tenant_idx"
|
||||||
|
ON "communication_rooms" USING btree ("tenant_id");
|
||||||
|
|
||||||
|
CREATE INDEX "communication_rooms_entity_idx"
|
||||||
|
ON "communication_rooms" USING btree ("tenant_id", "entity_type", "entity_id", "entity_uuid");
|
||||||
5
backend/db/migrations/0038_events_state.sql
Normal file
5
backend/db/migrations/0038_events_state.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "events" ADD COLUMN "state" text DEFAULT 'Final' NOT NULL;
|
||||||
|
|
||||||
|
UPDATE "events"
|
||||||
|
SET "state" = 'Final'
|
||||||
|
WHERE "state" IS NULL;
|
||||||
1
backend/db/migrations/0039_events_repeat_interval.sql
Normal file
1
backend/db/migrations/0039_events_repeat_interval.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "events" ADD COLUMN "repeatInterval" text DEFAULT 'Keine Wiederholung' NOT NULL;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
CREATE TABLE "notification_push_subscriptions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" bigint NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"endpoint" text NOT NULL,
|
||||||
|
"p256dh" text NOT NULL,
|
||||||
|
"auth" text NOT NULL,
|
||||||
|
"user_agent" text,
|
||||||
|
"device_label" text,
|
||||||
|
"meta" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"disabled_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "notification_push_subscriptions_endpoint_key" UNIQUE("endpoint")
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "notification_push_subscriptions"
|
||||||
|
ADD CONSTRAINT "notification_push_subscriptions_tenant_id_tenants_id_fk"
|
||||||
|
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
|
||||||
|
ON DELETE cascade ON UPDATE cascade;
|
||||||
|
|
||||||
|
ALTER TABLE "notification_push_subscriptions"
|
||||||
|
ADD CONSTRAINT "notification_push_subscriptions_user_id_auth_users_id_fk"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id")
|
||||||
|
ON DELETE cascade ON UPDATE cascade;
|
||||||
|
|
||||||
|
INSERT INTO "notifications_event_types" (
|
||||||
|
"event_key",
|
||||||
|
"display_name",
|
||||||
|
"description",
|
||||||
|
"category",
|
||||||
|
"severity",
|
||||||
|
"allowed_channels"
|
||||||
|
) VALUES
|
||||||
|
('system.test_push', 'Test-Push', 'Testet Desktop-Benachrichtigungen für den angemeldeten Nutzer.', 'system', 'info', '["inapp", "push"]'::jsonb),
|
||||||
|
('communication.message.new', 'Neue Chatnachricht', 'Benachrichtigt über relevante neue Chatnachrichten.', 'communication', 'info', '["inapp", "push"]'::jsonb),
|
||||||
|
('communication.call.started', 'Besprechung gestartet', 'Benachrichtigt Raumteilnehmer über gestartete Audio- oder Videoanrufe.', 'communication', 'info', '["inapp", "push"]'::jsonb),
|
||||||
|
('communication.call.missed', 'Verpasster Anruf', 'Benachrichtigt über verpasste Besprechungen.', 'communication', 'warning', '["inapp", "push"]'::jsonb),
|
||||||
|
('communication.room.invited', 'Raumeinladung', 'Benachrichtigt über Einladungen in Kommunikationsräume.', 'communication', 'info', '["inapp", "push"]'::jsonb)
|
||||||
|
ON CONFLICT ("event_key") DO UPDATE SET
|
||||||
|
"display_name" = EXCLUDED."display_name",
|
||||||
|
"description" = EXCLUDED."description",
|
||||||
|
"category" = EXCLUDED."category",
|
||||||
|
"severity" = EXCLUDED."severity",
|
||||||
|
"allowed_channels" = EXCLUDED."allowed_channels",
|
||||||
|
"is_active" = true;
|
||||||
6
backend/db/migrations/0040_filetag_system_types.sql
Normal file
6
backend/db/migrations/0040_filetag_system_types.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "filetags" ADD COLUMN "isSystemUsed" boolean DEFAULT false NOT NULL;
|
||||||
|
|
||||||
|
UPDATE "filetags"
|
||||||
|
SET "isSystemUsed" = true
|
||||||
|
WHERE COALESCE("createddocumenttype", '') <> ''
|
||||||
|
OR COALESCE("incomingDocumentType", '') <> '';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "auth_profiles"
|
||||||
|
ADD COLUMN "calendar_subscription_token" text;
|
||||||
2
backend/db/migrations/0042_profile_availability_note.sql
Normal file
2
backend/db/migrations/0042_profile_availability_note.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "auth_profiles"
|
||||||
|
ADD COLUMN IF NOT EXISTS "availability_note" text;
|
||||||
58
backend/db/migrations/0043_communication_rooms.sql
Normal file
58
backend/db/migrations/0043_communication_rooms.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "communication_rooms" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" bigint NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"topic" text,
|
||||||
|
"type" text DEFAULT 'room' NOT NULL,
|
||||||
|
"entity_type" text,
|
||||||
|
"entity_id" bigint,
|
||||||
|
"entity_uuid" uuid,
|
||||||
|
"matrix_room_id" text,
|
||||||
|
"matrix_alias" text,
|
||||||
|
"parent_space_room_id" text,
|
||||||
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone,
|
||||||
|
"created_by" uuid,
|
||||||
|
"updated_by" uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_tenant_id_tenants_id_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "communication_rooms"
|
||||||
|
ADD CONSTRAINT "communication_rooms_tenant_id_tenants_id_fk"
|
||||||
|
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
|
||||||
|
ON DELETE cascade ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_created_by_auth_users_id_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "communication_rooms"
|
||||||
|
ADD CONSTRAINT "communication_rooms_created_by_auth_users_id_fk"
|
||||||
|
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
|
||||||
|
ON DELETE no action ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_updated_by_auth_users_id_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "communication_rooms"
|
||||||
|
ADD CONSTRAINT "communication_rooms_updated_by_auth_users_id_fk"
|
||||||
|
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
|
||||||
|
ON DELETE no action ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "communication_rooms_tenant_key_idx"
|
||||||
|
ON "communication_rooms" USING btree ("tenant_id", "key");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "communication_rooms_tenant_idx"
|
||||||
|
ON "communication_rooms" USING btree ("tenant_id");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "communication_rooms_entity_idx"
|
||||||
|
ON "communication_rooms" USING btree ("tenant_id", "entity_type", "entity_id", "entity_uuid");
|
||||||
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;
|
||||||
@@ -243,16 +243,121 @@
|
|||||||
{
|
{
|
||||||
"idx": 34,
|
"idx": 34,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1778191200000,
|
"when": 1777420800000,
|
||||||
"tag": "0035_contract_history",
|
"tag": "0034_events_color",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 35,
|
"idx": 35,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
|
"when": 1778191200000,
|
||||||
|
"tag": "0035_contract_history",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "7",
|
||||||
"when": 1778194800000,
|
"when": 1778194800000,
|
||||||
"tag": "0036_allowed_contracttypes",
|
"tag": "0036_allowed_contracttypes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 37,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778840100000,
|
||||||
|
"tag": "0037_outgoing_sepa_mandates",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 38,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779158400000,
|
||||||
|
"tag": "0038_events_state",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 39,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779840000000,
|
||||||
|
"tag": "0039_events_repeat_interval",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 40,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779141600000,
|
||||||
|
"tag": "0040_filetag_system_types",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780149600000,
|
||||||
|
"tag": "0041_profile_calendar_subscription",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 42,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780153200000,
|
||||||
|
"tag": "0042_profile_availability_note",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 43,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780156800000,
|
||||||
|
"tag": "0043_communication_rooms",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 44,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780160400000,
|
||||||
|
"tag": "0044_telephony_calls",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 45,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780164000000,
|
||||||
|
"tag": "0045_telephony_trunks",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780167600000,
|
||||||
|
"tag": "0046_telephony_trunk_nat",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 47,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780171200000,
|
||||||
|
"tag": "0047_telephony_extensions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780174800000,
|
||||||
|
"tag": "0048_mobile_push_devices",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 49,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780178400000,
|
||||||
|
"tag": "0049_email_cache",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 50,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780261200000,
|
||||||
|
"tag": "0050_outgoing_document_costcentres",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const authProfiles = pgTable("auth_profiles", {
|
|||||||
|
|
||||||
email: text("email"),
|
email: text("email"),
|
||||||
token_id: text("token_id"),
|
token_id: text("token_id"),
|
||||||
|
calendar_subscription_token: text("calendar_subscription_token"),
|
||||||
|
|
||||||
weekly_working_days: doublePrecision("weekly_working_days"),
|
weekly_working_days: doublePrecision("weekly_working_days"),
|
||||||
|
|
||||||
|
|||||||
57
backend/db/schema/communication_rooms.ts
Normal file
57
backend/db/schema/communication_rooms.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
bigint,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
uniqueIndex,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const communicationRooms = pgTable(
|
||||||
|
"communication_rooms",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
key: text("key").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
topic: text("topic"),
|
||||||
|
type: text("type").notNull().default("room"),
|
||||||
|
|
||||||
|
entityType: text("entity_type"),
|
||||||
|
entityId: bigint("entity_id", { mode: "number" }),
|
||||||
|
entityUuid: uuid("entity_uuid"),
|
||||||
|
|
||||||
|
matrixRoomId: text("matrix_room_id"),
|
||||||
|
matrixAlias: text("matrix_alias"),
|
||||||
|
parentSpaceRoomId: text("parent_space_room_id"),
|
||||||
|
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
createdBy: uuid("created_by").references(() => authUsers.id),
|
||||||
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
tenantKeyIdx: uniqueIndex("communication_rooms_tenant_key_idx")
|
||||||
|
.on(table.tenantId, table.key),
|
||||||
|
tenantIdx: index("communication_rooms_tenant_idx")
|
||||||
|
.on(table.tenantId),
|
||||||
|
entityIdx: index("communication_rooms_entity_idx")
|
||||||
|
.on(table.tenantId, table.entityType, table.entityId, table.entityUuid),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export type CommunicationRoom = typeof communicationRooms.$inferSelect
|
||||||
|
export type NewCommunicationRoom = typeof communicationRooms.$inferInsert
|
||||||
@@ -13,6 +13,7 @@ import { customers } from "./customers"
|
|||||||
import { contacts } from "./contacts"
|
import { contacts } from "./contacts"
|
||||||
import { contracttypes } from "./contracttypes"
|
import { contracttypes } from "./contracttypes"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
|
import { outgoingsepamandates } from "./outgoingsepamandates"
|
||||||
|
|
||||||
export const contracts = pgTable(
|
export const contracts = pgTable(
|
||||||
"contracts",
|
"contracts",
|
||||||
@@ -60,6 +61,9 @@ export const contracts = pgTable(
|
|||||||
bankingOwner: text("bankingOwner"),
|
bankingOwner: text("bankingOwner"),
|
||||||
sepaRef: text("sepaRef"),
|
sepaRef: text("sepaRef"),
|
||||||
sepaDate: timestamp("sepaDate", { withTimezone: true }),
|
sepaDate: timestamp("sepaDate", { withTimezone: true }),
|
||||||
|
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||||
|
() => outgoingsepamandates.id
|
||||||
|
),
|
||||||
|
|
||||||
paymentType: text("paymentType"),
|
paymentType: text("paymentType"),
|
||||||
billingInterval: text("billingInterval"),
|
billingInterval: text("billingInterval"),
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { projects } from "./projects"
|
|||||||
import { plants } from "./plants"
|
import { plants } from "./plants"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
import {serialExecutions} from "./serialexecutions";
|
import {serialExecutions} from "./serialexecutions";
|
||||||
|
import { outgoingsepamandates } from "./outgoingsepamandates"
|
||||||
|
import { costcentres } from "./costcentres"
|
||||||
|
|
||||||
export const createddocuments = pgTable("createddocuments", {
|
export const createddocuments = pgTable("createddocuments", {
|
||||||
id: bigint("id", { mode: "number" })
|
id: bigint("id", { mode: "number" })
|
||||||
@@ -48,6 +50,8 @@ export const createddocuments = pgTable("createddocuments", {
|
|||||||
() => projects.id
|
() => projects.id
|
||||||
),
|
),
|
||||||
|
|
||||||
|
costcentre: uuid("costcentre").references(() => costcentres.id),
|
||||||
|
|
||||||
documentNumber: text("documentNumber"),
|
documentNumber: text("documentNumber"),
|
||||||
documentDate: text("documentDate"),
|
documentDate: text("documentDate"),
|
||||||
|
|
||||||
@@ -118,6 +122,10 @@ export const createddocuments = pgTable("createddocuments", {
|
|||||||
() => contracts.id
|
() => contracts.id
|
||||||
),
|
),
|
||||||
|
|
||||||
|
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||||
|
() => outgoingsepamandates.id
|
||||||
|
),
|
||||||
|
|
||||||
serialexecution: uuid("serialexecution").references(() => serialExecutions.id)
|
serialexecution: uuid("serialexecution").references(() => serialExecutions.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -32,6 +32,9 @@ export const events = pgTable(
|
|||||||
|
|
||||||
eventtype: text("eventtype").default("Umsetzung"),
|
eventtype: text("eventtype").default("Umsetzung"),
|
||||||
quick: boolean("quick").notNull().default(false),
|
quick: boolean("quick").notNull().default(false),
|
||||||
|
state: text("state").notNull().default("Final"),
|
||||||
|
color: text("color"),
|
||||||
|
repeatInterval: text("repeatInterval").notNull().default("Keine Wiederholung"),
|
||||||
|
|
||||||
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
|
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const filetags = pgTable("filetags", {
|
|||||||
createdDocumentType: text("createddocumenttype").default(""),
|
createdDocumentType: text("createddocumenttype").default(""),
|
||||||
incomingDocumentType: text("incomingDocumentType"),
|
incomingDocumentType: text("incomingDocumentType"),
|
||||||
|
|
||||||
|
isSystemUsed: boolean("isSystemUsed").notNull().default(false),
|
||||||
|
|
||||||
archived: boolean("archived").notNull().default(false),
|
archived: boolean("archived").notNull().default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { authUsers } from "./auth_users"
|
|||||||
import {files} from "./files";
|
import {files} from "./files";
|
||||||
import { memberrelations } from "./memberrelations";
|
import { memberrelations } from "./memberrelations";
|
||||||
import { contracts } from "./contracts";
|
import { contracts } from "./contracts";
|
||||||
|
import { outgoingsepamandates } from "./outgoingsepamandates";
|
||||||
|
|
||||||
export const historyitems = pgTable("historyitems", {
|
export const historyitems = pgTable("historyitems", {
|
||||||
id: bigint("id", { mode: "number" })
|
id: bigint("id", { mode: "number" })
|
||||||
@@ -114,6 +115,11 @@ export const historyitems = pgTable("historyitems", {
|
|||||||
|
|
||||||
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
|
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
|
||||||
|
|
||||||
|
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||||
|
() => outgoingsepamandates.id,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
|
||||||
config: jsonb("config"),
|
config: jsonb("config"),
|
||||||
|
|
||||||
projecttype: bigint("projecttype", { mode: "number" }).references(
|
projecttype: bigint("projecttype", { mode: "number" }).references(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export * from "./branches"
|
|||||||
export * from "./checkexecutions"
|
export * from "./checkexecutions"
|
||||||
export * from "./checks"
|
export * from "./checks"
|
||||||
export * from "./citys"
|
export * from "./citys"
|
||||||
|
export * from "./communication_rooms"
|
||||||
export * from "./contacts"
|
export * from "./contacts"
|
||||||
export * from "./contracts"
|
export * from "./contracts"
|
||||||
export * from "./contracttypes"
|
export * from "./contracttypes"
|
||||||
@@ -26,6 +27,7 @@ export * from "./customerspaces"
|
|||||||
export * from "./customerinventoryitems"
|
export * from "./customerinventoryitems"
|
||||||
export * from "./devices"
|
export * from "./devices"
|
||||||
export * from "./documentboxes"
|
export * from "./documentboxes"
|
||||||
|
export * from "./emails"
|
||||||
export * from "./enums"
|
export * from "./enums"
|
||||||
export * from "./events"
|
export * from "./events"
|
||||||
export * from "./entitybankaccounts"
|
export * from "./entitybankaccounts"
|
||||||
@@ -46,6 +48,8 @@ export * from "./historyitems"
|
|||||||
export * from "./holidays"
|
export * from "./holidays"
|
||||||
export * from "./hourrates"
|
export * from "./hourrates"
|
||||||
export * from "./incominginvoices"
|
export * from "./incominginvoices"
|
||||||
|
export * from "./instance_agents"
|
||||||
|
export * from "./instance_agent_scan_jobs"
|
||||||
export * from "./inventoryitemgroups"
|
export * from "./inventoryitemgroups"
|
||||||
export * from "./inventoryitems"
|
export * from "./inventoryitems"
|
||||||
export * from "./letterheads"
|
export * from "./letterheads"
|
||||||
@@ -54,9 +58,12 @@ export * from "./movements"
|
|||||||
export * from "./m2m_api_keys"
|
export * from "./m2m_api_keys"
|
||||||
export * from "./notifications_event_types"
|
export * from "./notifications_event_types"
|
||||||
export * from "./notifications_items"
|
export * from "./notifications_items"
|
||||||
|
export * from "./notification_mobile_push_devices"
|
||||||
export * from "./notifications_preferences"
|
export * from "./notifications_preferences"
|
||||||
export * from "./notifications_preferences_defaults"
|
export * from "./notifications_preferences_defaults"
|
||||||
|
export * from "./notification_push_subscriptions"
|
||||||
export * from "./ownaccounts"
|
export * from "./ownaccounts"
|
||||||
|
export * from "./outgoingsepamandates"
|
||||||
export * from "./plants"
|
export * from "./plants"
|
||||||
export * from "./productcategories"
|
export * from "./productcategories"
|
||||||
export * from "./products"
|
export * from "./products"
|
||||||
@@ -72,6 +79,9 @@ export * from "./statementallocations"
|
|||||||
export * from "./tasks"
|
export * from "./tasks"
|
||||||
export * from "./teams"
|
export * from "./teams"
|
||||||
export * from "./taxtypes"
|
export * from "./taxtypes"
|
||||||
|
export * from "./telephony_calls"
|
||||||
|
export * from "./telephony_extensions"
|
||||||
|
export * from "./telephony_trunks"
|
||||||
export * from "./tenants"
|
export * from "./tenants"
|
||||||
export * from "./texttemplates"
|
export * from "./texttemplates"
|
||||||
export * from "./units"
|
export * from "./units"
|
||||||
|
|||||||
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
|
||||||
50
backend/db/schema/notification_push_subscriptions.ts
Normal file
50
backend/db/schema/notification_push_subscriptions.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
bigint,
|
||||||
|
text,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const notificationPushSubscriptions = pgTable(
|
||||||
|
"notification_push_subscriptions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
|
||||||
|
endpoint: text("endpoint").notNull(),
|
||||||
|
p256dh: text("p256dh").notNull(),
|
||||||
|
auth: text("auth").notNull(),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
deviceLabel: text("device_label"),
|
||||||
|
meta: jsonb("meta"),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
uniqueEndpoint: uniqueIndex("notification_push_subscriptions_endpoint_key").on(table.endpoint),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type NotificationPushSubscription =
|
||||||
|
typeof notificationPushSubscriptions.$inferSelect
|
||||||
|
export type NewNotificationPushSubscription =
|
||||||
|
typeof notificationPushSubscriptions.$inferInsert
|
||||||
61
backend/db/schema/outgoingsepamandates.ts
Normal file
61
backend/db/schema/outgoingsepamandates.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
bigint,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { customers } from "./customers"
|
||||||
|
import { entitybankaccounts } from "./entitybankaccounts"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const outgoingsepamandates = pgTable("outgoingsepamandates", {
|
||||||
|
id: bigint("id", { mode: "number" })
|
||||||
|
.primaryKey()
|
||||||
|
.generatedByDefaultAsIdentity(),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id),
|
||||||
|
|
||||||
|
customer: bigint("customer", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => customers.id),
|
||||||
|
|
||||||
|
bankaccount: bigint("bankaccount", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => entitybankaccounts.id),
|
||||||
|
|
||||||
|
reference: text("reference").notNull(),
|
||||||
|
|
||||||
|
status: text("status").notNull().default("Entwurf"),
|
||||||
|
|
||||||
|
mandateType: text("mandate_type").notNull().default("CORE"),
|
||||||
|
|
||||||
|
sequenceType: text("sequence_type").notNull().default("RCUR"),
|
||||||
|
|
||||||
|
signedAt: timestamp("signed_at", { withTimezone: true }),
|
||||||
|
|
||||||
|
validFrom: timestamp("valid_from", { withTimezone: true }),
|
||||||
|
|
||||||
|
validUntil: timestamp("valid_until", { withTimezone: true }),
|
||||||
|
|
||||||
|
defaultMandate: boolean("default_mandate").notNull().default(false),
|
||||||
|
|
||||||
|
notes: text("notes"),
|
||||||
|
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type OutgoingSepaMandate = typeof outgoingsepamandates.$inferSelect
|
||||||
|
export type NewOutgoingSepaMandate = typeof outgoingsepamandates.$inferInsert
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
import { units } from "./units"
|
import { units } from "./units"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
|
import { costcentres } from "./costcentres"
|
||||||
|
|
||||||
export const services = pgTable("services", {
|
export const services = pgTable("services", {
|
||||||
id: bigint("id", { mode: "number" })
|
id: bigint("id", { mode: "number" })
|
||||||
@@ -35,6 +36,8 @@ export const services = pgTable("services", {
|
|||||||
|
|
||||||
unit: bigint("unit", { mode: "number" }).references(() => units.id),
|
unit: bigint("unit", { mode: "number" }).references(() => units.id),
|
||||||
|
|
||||||
|
costcentre: uuid("costcentre").references(() => costcentres.id),
|
||||||
|
|
||||||
serviceNumber: bigint("serviceNumber", { mode: "number" }),
|
serviceNumber: bigint("serviceNumber", { mode: "number" }),
|
||||||
|
|
||||||
tags: jsonb("tags").default([]),
|
tags: jsonb("tags").default([]),
|
||||||
|
|||||||
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
|
||||||
@@ -91,6 +91,7 @@ export const tenants = pgTable(
|
|||||||
createDocument: true,
|
createDocument: true,
|
||||||
serialInvoice: true,
|
serialInvoice: true,
|
||||||
incomingInvoices: true,
|
incomingInvoices: true,
|
||||||
|
outgoingsepamandates: true,
|
||||||
costcentres: true,
|
costcentres: true,
|
||||||
branches: true,
|
branches: true,
|
||||||
teams: true,
|
teams: true,
|
||||||
@@ -140,6 +141,7 @@ export const tenants = pgTable(
|
|||||||
customerinventoryitems: { prefix: "KIA-", suffix: "", nextNumber: 1000 },
|
customerinventoryitems: { prefix: "KIA-", suffix: "", nextNumber: 1000 },
|
||||||
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
|
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
|
||||||
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
|
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
|
||||||
|
outgoingsepamandates: { prefix: "SEPA-", suffix: "", nextNumber: 1000 },
|
||||||
}),
|
}),
|
||||||
accountChart: text("accountChart").notNull().default("skr03"),
|
accountChart: text("accountChart").notNull().default("skr03"),
|
||||||
|
|
||||||
|
|||||||
7
backend/docker-entrypoint.sh
Normal file
7
backend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${FEDEO_RUN_MIGRATIONS:-true}" = "true" ]; then
|
||||||
|
npm run migrate
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec node dist/src/index.js
|
||||||
10423
backend/package-lock.json
generated
Normal file
10423
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,7 @@
|
|||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"webdav-server": "^2.6.2",
|
"webdav-server": "^2.6.2",
|
||||||
"xmlbuilder": "^15.1.1",
|
"xmlbuilder": "^15.1.1",
|
||||||
"zpl-image": "^0.2.0",
|
"zpl-image": "^0.2.0",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"prisma": "^6.15.0",
|
"prisma": "^6.15.0",
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-aut
|
|||||||
import wikiRoutes from "./routes/wiki";
|
import wikiRoutes from "./routes/wiki";
|
||||||
import portalContractRoutes from "./routes/portal/contracts";
|
import portalContractRoutes from "./routes/portal/contracts";
|
||||||
import mcpRoutes from "./routes/mcp";
|
import mcpRoutes from "./routes/mcp";
|
||||||
|
import communicationRoutes from "./routes/communication";
|
||||||
|
import telephonyRoutes from "./routes/telephony";
|
||||||
|
import instanceAgentRoutes from "./routes/instanceAgents";
|
||||||
|
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
|
||||||
|
|
||||||
//Public Links
|
//Public Links
|
||||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||||
@@ -55,6 +59,8 @@ import {sendMail} from "./utils/mailer";
|
|||||||
import {loadSecrets, secrets} from "./utils/secrets";
|
import {loadSecrets, secrets} from "./utils/secrets";
|
||||||
import {initMailer} from "./utils/mailer"
|
import {initMailer} from "./utils/mailer"
|
||||||
import {initS3} from "./utils/s3";
|
import {initS3} from "./utils/s3";
|
||||||
|
import { runBootstrap } from "./modules/bootstrap.service";
|
||||||
|
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
|
||||||
|
|
||||||
|
|
||||||
//Services
|
//Services
|
||||||
@@ -79,6 +85,8 @@ async function main() {
|
|||||||
await app.register(dayjsPlugin);
|
await app.register(dayjsPlugin);
|
||||||
await app.register(dbPlugin);
|
await app.register(dbPlugin);
|
||||||
await app.register(servicesPlugin);
|
await app.register(servicesPlugin);
|
||||||
|
await runBootstrap(app);
|
||||||
|
startMatrixPushWorker(app);
|
||||||
|
|
||||||
app.addHook('preHandler', (req, reply, done) => {
|
app.addHook('preHandler', (req, reply, done) => {
|
||||||
console.log(req.method)
|
console.log(req.method)
|
||||||
@@ -122,6 +130,10 @@ async function main() {
|
|||||||
await devicesApp.register(devicesManagementRoutes)
|
await devicesApp.register(devicesManagementRoutes)
|
||||||
},{prefix: "/devices"})
|
},{prefix: "/devices"})
|
||||||
|
|
||||||
|
await app.register(async (agentApp) => {
|
||||||
|
await agentApp.register(instanceAgentGatewayRoutes)
|
||||||
|
},{prefix: "/instance-agent"})
|
||||||
|
|
||||||
await app.register(corsPlugin);
|
await app.register(corsPlugin);
|
||||||
|
|
||||||
//Geschützte Routes
|
//Geschützte Routes
|
||||||
@@ -150,6 +162,9 @@ async function main() {
|
|||||||
await subApp.register(wikiRoutes);
|
await subApp.register(wikiRoutes);
|
||||||
await subApp.register(portalContractRoutes);
|
await subApp.register(portalContractRoutes);
|
||||||
await subApp.register(mcpRoutes);
|
await subApp.register(mcpRoutes);
|
||||||
|
await subApp.register(communicationRoutes);
|
||||||
|
await subApp.register(telephonyRoutes);
|
||||||
|
await subApp.register(instanceAgentRoutes);
|
||||||
|
|
||||||
},{prefix: "/api"})
|
},{prefix: "/api"})
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
|
filetags,
|
||||||
files,
|
files,
|
||||||
|
folders,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||||
|
import { saveFile } from "../../utils/files"
|
||||||
import { McpTool } from "../types"
|
import { McpTool } from "../types"
|
||||||
|
|
||||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||||
@@ -28,6 +32,7 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
|
|||||||
|
|
||||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||||
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
||||||
|
const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
const allowedOutgoingDocumentTypes = new Set([
|
const allowedOutgoingDocumentTypes = new Set([
|
||||||
"quotes",
|
"quotes",
|
||||||
@@ -98,7 +103,7 @@ const applyOutgoingDocumentTaxType = (
|
|||||||
const rows = Array.isArray(payload.rows)
|
const rows = Array.isArray(payload.rows)
|
||||||
? payload.rows
|
? payload.rows
|
||||||
: Array.isArray(existingRows)
|
: Array.isArray(existingRows)
|
||||||
? existingRows
|
? normalizeOutgoingDocumentRows(existingRows)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (!rows) return
|
if (!rows) return
|
||||||
@@ -120,6 +125,44 @@ const optionalArrayArg = (args: Record<string, unknown>, key: string) => {
|
|||||||
return Array.isArray(value) ? value : null
|
return Array.isArray(value) ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeOutgoingDocumentRow = (row: unknown, index: number) => {
|
||||||
|
let normalizedRow = row
|
||||||
|
|
||||||
|
if (typeof normalizedRow === "string") {
|
||||||
|
try {
|
||||||
|
normalizedRow = JSON.parse(normalizedRow)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Position ${index + 1} ist kein gültiges JSON-Objekt`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedRow || typeof normalizedRow !== "object" || Array.isArray(normalizedRow)) {
|
||||||
|
throw new Error(`Position ${index + 1} muss ein Objekt sein`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowPayload = { ...(normalizedRow as Record<string, any>) }
|
||||||
|
|
||||||
|
rowPayload.id = rowPayload.id || randomUUID()
|
||||||
|
rowPayload.pos = rowPayload.pos || String(index + 1)
|
||||||
|
rowPayload.mode = rowPayload.mode || "free"
|
||||||
|
rowPayload.inputPrice = hasValidNumber(rowPayload.inputPrice)
|
||||||
|
? Number(rowPayload.inputPrice)
|
||||||
|
: hasValidNumber(rowPayload.price)
|
||||||
|
? Number(rowPayload.price)
|
||||||
|
: 0
|
||||||
|
rowPayload.price = hasValidNumber(rowPayload.price) ? Number(rowPayload.price) : rowPayload.inputPrice
|
||||||
|
rowPayload.quantity = hasValidNumber(rowPayload.quantity) ? Number(rowPayload.quantity) : 1
|
||||||
|
rowPayload.discountPercent = hasValidNumber(rowPayload.discountPercent) ? Number(rowPayload.discountPercent) : 0
|
||||||
|
rowPayload.linkedEntitys = Array.isArray(rowPayload.linkedEntitys) ? rowPayload.linkedEntitys : []
|
||||||
|
|
||||||
|
return rowPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeOutgoingDocumentRows = (rows: unknown) => {
|
||||||
|
if (!Array.isArray(rows)) return []
|
||||||
|
return rows.map((row, index) => normalizeOutgoingDocumentRow(row, index))
|
||||||
|
}
|
||||||
|
|
||||||
const buildOutgoingDocumentPayload = (
|
const buildOutgoingDocumentPayload = (
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -138,7 +181,7 @@ const buildOutgoingDocumentPayload = (
|
|||||||
payload.archived = false
|
payload.archived = false
|
||||||
payload.state = stringArg(args, "state") || "Entwurf"
|
payload.state = stringArg(args, "state") || "Entwurf"
|
||||||
payload.type = documentTypeArg(args)
|
payload.type = documentTypeArg(args)
|
||||||
payload.rows = optionalArrayArg(args, "rows") || []
|
payload.rows = normalizeOutgoingDocumentRows(optionalArrayArg(args, "rows") || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
const stringFields = [
|
const stringFields = [
|
||||||
@@ -162,6 +205,8 @@ const buildOutgoingDocumentPayload = (
|
|||||||
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.costcentre !== undefined) payload.costcentre = stringArg(args, "costcentre")
|
||||||
|
|
||||||
for (const field of ["paymentDays"] as const) {
|
for (const field of ["paymentDays"] as const) {
|
||||||
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
||||||
}
|
}
|
||||||
@@ -172,7 +217,7 @@ const buildOutgoingDocumentPayload = (
|
|||||||
if (args.agriculture !== undefined) payload.agriculture = optionalObjectArg(args, "agriculture")
|
if (args.agriculture !== undefined) payload.agriculture = optionalObjectArg(args, "agriculture")
|
||||||
if (args.report !== undefined) payload.report = optionalObjectArg(args, "report") || {}
|
if (args.report !== undefined) payload.report = optionalObjectArg(args, "report") || {}
|
||||||
if (args.serialConfig !== undefined) payload.serialConfig = optionalObjectArg(args, "serialConfig") || {}
|
if (args.serialConfig !== undefined) payload.serialConfig = optionalObjectArg(args, "serialConfig") || {}
|
||||||
if (args.rows !== undefined) payload.rows = optionalArrayArg(args, "rows") || []
|
if (args.rows !== undefined) payload.rows = normalizeOutgoingDocumentRows(optionalArrayArg(args, "rows") || [])
|
||||||
if (args.usedAdvanceInvoices !== undefined) payload.usedAdvanceInvoices = optionalArrayArg(args, "usedAdvanceInvoices") || []
|
if (args.usedAdvanceInvoices !== undefined) payload.usedAdvanceInvoices = optionalArrayArg(args, "usedAdvanceInvoices") || []
|
||||||
if (typeof args.availableInPortal === "boolean") payload.availableInPortal = args.availableInPortal
|
if (typeof args.availableInPortal === "boolean") payload.availableInPortal = args.availableInPortal
|
||||||
if (typeof args.advanceInvoiceResolved === "boolean") payload.advanceInvoiceResolved = args.advanceInvoiceResolved
|
if (typeof args.advanceInvoiceResolved === "boolean") payload.advanceInvoiceResolved = args.advanceInvoiceResolved
|
||||||
@@ -193,6 +238,49 @@ const incomingInvoiceAccountsArg = (args: Record<string, unknown>) => {
|
|||||||
return args.accounts
|
return args.accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const base64BufferArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = stringArg(args, key)
|
||||||
|
if (!value) throw new Error(`${key} ist erforderlich`)
|
||||||
|
|
||||||
|
const base64 = value.includes(",") ? value.split(",").pop() || "" : value
|
||||||
|
const buffer = Buffer.from(base64, "base64")
|
||||||
|
|
||||||
|
if (!buffer.length) throw new Error(`${key} enthält keine Datei`)
|
||||||
|
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId: number }) => {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
const [tag] = await context.server.db
|
||||||
|
.select({ id: filetags.id })
|
||||||
|
.from(filetags)
|
||||||
|
.where(and(
|
||||||
|
eq(filetags.tenant, context.tenantId),
|
||||||
|
eq(filetags.incomingDocumentType, "invoices"),
|
||||||
|
eq(filetags.archived, false)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const [folder] = await context.server.db
|
||||||
|
.select({ id: folders.id })
|
||||||
|
.from(folders)
|
||||||
|
.where(and(
|
||||||
|
eq(folders.tenant, context.tenantId),
|
||||||
|
eq(folders.function, "incomingInvoices"),
|
||||||
|
eq(folders.year, currentYear),
|
||||||
|
eq(folders.archived, false)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
folderId: folder?.id || null,
|
||||||
|
fileTagId: tag?.id || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buildIncomingInvoicePayload = (
|
const buildIncomingInvoicePayload = (
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -372,6 +460,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
contact: { type: "number" },
|
contact: { type: "number" },
|
||||||
contract: { type: "number" },
|
contract: { type: "number" },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
|
costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." },
|
||||||
plant: { type: "number" },
|
plant: { type: "number" },
|
||||||
documentDate: { type: "string" },
|
documentDate: { type: "string" },
|
||||||
deliveryDate: { type: "string" },
|
deliveryDate: { type: "string" },
|
||||||
@@ -426,6 +515,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
contact: { type: "number" },
|
contact: { type: "number" },
|
||||||
contract: { type: "number" },
|
contract: { type: "number" },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
|
costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." },
|
||||||
plant: { type: "number" },
|
plant: { type: "number" },
|
||||||
documentDate: { type: "string" },
|
documentDate: { type: "string" },
|
||||||
deliveryDate: { type: "string" },
|
deliveryDate: { type: "string" },
|
||||||
@@ -694,6 +784,124 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { rows }
|
return { rows }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.incoming_invoices.files.upload",
|
||||||
|
title: "Datei für Eingangsbeleg hochladen",
|
||||||
|
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.",
|
||||||
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["filename", "contentBase64"],
|
||||||
|
properties: {
|
||||||
|
filename: { type: "string", description: "Dateiname, z. B. rechnung.pdf." },
|
||||||
|
contentBase64: { type: "string", description: "Dateiinhalt als Base64 oder Data-URL." },
|
||||||
|
mimeType: { type: "string", description: "MIME-Type, z. B. application/pdf oder image/jpeg." },
|
||||||
|
invoiceId: { type: "number", description: "Optional: vorhandener Eingangsbeleg, mit dem die Datei verknüpft wird." },
|
||||||
|
prepare: {
|
||||||
|
type: "boolean",
|
||||||
|
default: true,
|
||||||
|
description: "Wenn kein invoiceId und keine invoice-Daten übergeben werden, wird nach dem Upload die automatische Eingangsbeleg-Vorbereitung ausgeführt.",
|
||||||
|
},
|
||||||
|
invoice: {
|
||||||
|
type: "object",
|
||||||
|
description: "Optional: Daten für einen neuen Eingangsbeleg-Entwurf, der direkt mit der Datei verknüpft wird.",
|
||||||
|
properties: {
|
||||||
|
vendor: { type: "number" },
|
||||||
|
reference: { type: "string" },
|
||||||
|
date: { type: "string" },
|
||||||
|
dueDate: { type: "string" },
|
||||||
|
document: { type: "number" },
|
||||||
|
description: { type: "string" },
|
||||||
|
paymentType: { type: "string" },
|
||||||
|
accounts: { type: "array" },
|
||||||
|
expense: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const filename = stringArg(args, "filename")
|
||||||
|
if (!filename) throw new Error("filename ist erforderlich")
|
||||||
|
|
||||||
|
const buffer = base64BufferArg(args, "contentBase64")
|
||||||
|
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
|
||||||
|
const invoiceId = numberArg(args, "invoiceId")
|
||||||
|
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
|
||||||
|
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
|
||||||
|
|
||||||
|
let linkedInvoice: any = null
|
||||||
|
|
||||||
|
if (invoiceId) {
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||||
|
linkedInvoice = existing
|
||||||
|
} else if (invoiceArgs) {
|
||||||
|
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
||||||
|
payload.state = "Entwurf"
|
||||||
|
|
||||||
|
const [createdInvoice] = await context.server.db
|
||||||
|
.insert(incominginvoices)
|
||||||
|
.values(payload as any)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
linkedInvoice = createdInvoice
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
||||||
|
const saved = await saveFile(
|
||||||
|
context.server,
|
||||||
|
context.tenantId,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
filename,
|
||||||
|
content: buffer,
|
||||||
|
contentType: mimeType,
|
||||||
|
},
|
||||||
|
defaults.folderId,
|
||||||
|
defaults.fileTagId,
|
||||||
|
{
|
||||||
|
incominginvoice: linkedInvoice?.id || null,
|
||||||
|
createdBy: context.userId,
|
||||||
|
updatedBy: context.userId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
||||||
|
|
||||||
|
if (shouldPrepare) {
|
||||||
|
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [file] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(files)
|
||||||
|
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (file?.incominginvoice && !linkedInvoice) {
|
||||||
|
const [preparedInvoice] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
linkedInvoice = preparedInvoice || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
invoice: linkedInvoice,
|
||||||
|
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
||||||
|
prepared: Boolean(shouldPrepare && linkedInvoice),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.validate",
|
name: "accounting.incoming_invoices.validate",
|
||||||
title: "Eingangsbeleg validieren",
|
title: "Eingangsbeleg validieren",
|
||||||
|
|||||||
506
backend/src/modules/bootstrap.service.ts
Normal file
506
backend/src/modules/bootstrap.service.ts
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import bcrypt from "bcrypt"
|
||||||
|
|
||||||
|
import {
|
||||||
|
accounts,
|
||||||
|
authProfiles,
|
||||||
|
authRoles,
|
||||||
|
authRolePermissions,
|
||||||
|
authTenantUsers,
|
||||||
|
authUserRoles,
|
||||||
|
authUsers,
|
||||||
|
branches,
|
||||||
|
filetags,
|
||||||
|
folders,
|
||||||
|
productcategories,
|
||||||
|
servicecategories,
|
||||||
|
taxTypes,
|
||||||
|
teams,
|
||||||
|
tenants,
|
||||||
|
texttemplates,
|
||||||
|
units,
|
||||||
|
} from "../../db/schema"
|
||||||
|
import { matrixService } from "./matrix.service"
|
||||||
|
|
||||||
|
const adminPermissions = [
|
||||||
|
"mcp.tokens.write",
|
||||||
|
"staff.time.read_all",
|
||||||
|
"masterdata.customers.read",
|
||||||
|
"masterdata.vendors.read",
|
||||||
|
"masterdata.contacts.read",
|
||||||
|
"masterdata.products.read",
|
||||||
|
"masterdata.services.read",
|
||||||
|
"masterdata.cost_centres.read",
|
||||||
|
"masterdata.branches.read",
|
||||||
|
"masterdata.teams.read",
|
||||||
|
"masterdata.vehicles.read",
|
||||||
|
"masterdata.inventory_items.read",
|
||||||
|
"masterdata.units.read",
|
||||||
|
"accounting.outgoing_documents.read",
|
||||||
|
"accounting.outgoing_documents.write",
|
||||||
|
"accounting.accounts.read",
|
||||||
|
"accounting.incoming_invoices.read",
|
||||||
|
"accounting.incoming_invoices.write",
|
||||||
|
"accounting.bank.read",
|
||||||
|
"accounting.statement_allocations.read",
|
||||||
|
"organisation.customers.read",
|
||||||
|
"organisation.projects.read",
|
||||||
|
"organisation.plants.read",
|
||||||
|
"organisation.events.read",
|
||||||
|
"organisation.tasks.read",
|
||||||
|
"organisation.tasks.write",
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultUnits = [
|
||||||
|
{ name: "Stück", single: "Stück", multiple: "Stück", short: "Stk.", step: "1" },
|
||||||
|
{ name: "Stunde", single: "Stunde", multiple: "Stunden", short: "Std.", step: "0.25" },
|
||||||
|
{ name: "Pauschale", single: "Pauschale", multiple: "Pauschalen", short: "Psch.", step: "1" },
|
||||||
|
{ name: "Meter", single: "Meter", multiple: "Meter", short: "m", step: "0.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultTaxTypes = [
|
||||||
|
{ label: "Umsatzsteuer 19%", percentage: 19 },
|
||||||
|
{ label: "Umsatzsteuer 7%", percentage: 7 },
|
||||||
|
{ label: "Steuerfrei", percentage: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultAccounts = [
|
||||||
|
{ number: "8400", label: "Erlöse 19% USt", accountChart: "skr03" },
|
||||||
|
{ number: "8300", label: "Erlöse 7% USt", accountChart: "skr03" },
|
||||||
|
{ number: "1200", label: "Bank", accountChart: "skr03" },
|
||||||
|
{ number: "1000", label: "Kasse", accountChart: "skr03" },
|
||||||
|
{ number: "1400", label: "Forderungen aus Lieferungen und Leistungen", accountChart: "skr03" },
|
||||||
|
{ number: "1600", label: "Verbindlichkeiten aus Lieferungen und Leistungen", accountChart: "skr03" },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function ensureGlobalDefaults(server: FastifyInstance, userId: string) {
|
||||||
|
for (const unit of defaultUnits) {
|
||||||
|
const existing = await server.db.select({ id: units.id }).from(units).where(eq(units.name, unit.name)).limit(1)
|
||||||
|
if (!existing.length) await server.db.insert(units).values(unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const taxType of defaultTaxTypes) {
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: taxTypes.id })
|
||||||
|
.from(taxTypes)
|
||||||
|
.where(eq(taxTypes.percentage, taxType.percentage))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(taxTypes).values({
|
||||||
|
...taxType,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const account of defaultAccounts) {
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: accounts.id })
|
||||||
|
.from(accounts)
|
||||||
|
.where(and(eq(accounts.accountChart, account.accountChart), eq(accounts.number, account.number)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(accounts).values({
|
||||||
|
...account,
|
||||||
|
description: "FEDEO Standardkonto",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: number, userId: string) {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const timestamp = new Date()
|
||||||
|
|
||||||
|
const tagDefaults = [
|
||||||
|
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices", isSystemUsed: true },
|
||||||
|
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes", isSystemUsed: true },
|
||||||
|
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders", isSystemUsed: true },
|
||||||
|
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes", isSystemUsed: true },
|
||||||
|
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices", isSystemUsed: true },
|
||||||
|
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders", isSystemUsed: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const tag of tagDefaults) {
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: filetags.id })
|
||||||
|
.from(filetags)
|
||||||
|
.where(and(eq(filetags.tenant, tenantId), eq(filetags.name, tag.name)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(filetags).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
...tag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTags = await server.db.select().from(filetags).where(eq(filetags.tenant, tenantId))
|
||||||
|
const tagByCreatedType = new Map(allTags.map((tag) => [tag.createdDocumentType, tag.id]))
|
||||||
|
const tagByIncomingType = new Map(allTags.map((tag) => [tag.incomingDocumentType, tag.id]))
|
||||||
|
|
||||||
|
const rootFolders = [
|
||||||
|
{ name: "Ausgangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-document-text" },
|
||||||
|
{ name: "Angebote", function: "yearSubCategory" as const, icon: "i-heroicons-document-duplicate" },
|
||||||
|
{ name: "Auftragsbestätigungen", function: "yearSubCategory" as const, icon: "i-heroicons-clipboard-document-check" },
|
||||||
|
{ name: "Lieferscheine", function: "yearSubCategory" as const, icon: "i-heroicons-truck" },
|
||||||
|
{ name: "Eingangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-inbox-arrow-down" },
|
||||||
|
{ name: "Belege Bankeinzahlung", function: "yearSubCategory" as const, icon: "i-heroicons-banknotes" },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const folder of rootFolders) {
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: folders.id })
|
||||||
|
.from(folders)
|
||||||
|
.where(and(eq(folders.tenant, tenantId), eq(folders.name, folder.name)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(folders).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
name: folder.name,
|
||||||
|
function: folder.function,
|
||||||
|
icon: folder.icon,
|
||||||
|
isSystemUsed: true,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFolders = await server.db.select().from(folders).where(eq(folders.tenant, tenantId))
|
||||||
|
const rootFolderByName = new Map(allFolders.filter((folder) => !folder.parent).map((folder) => [folder.name, folder.id]))
|
||||||
|
|
||||||
|
const yearFolders = [
|
||||||
|
{
|
||||||
|
parentName: "Ausgangsrechnungen",
|
||||||
|
function: "invoices" as const,
|
||||||
|
icon: "i-heroicons-document-text",
|
||||||
|
standardFiletype: tagByCreatedType.get("invoices"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentName: "Angebote",
|
||||||
|
function: "quotes" as const,
|
||||||
|
icon: "i-heroicons-document-duplicate",
|
||||||
|
standardFiletype: tagByCreatedType.get("quotes"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentName: "Auftragsbestätigungen",
|
||||||
|
function: "confirmationOrders" as const,
|
||||||
|
icon: "i-heroicons-clipboard-document-check",
|
||||||
|
standardFiletype: tagByCreatedType.get("confirmationOrders"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentName: "Lieferscheine",
|
||||||
|
function: "deliveryNotes" as const,
|
||||||
|
icon: "i-heroicons-truck",
|
||||||
|
standardFiletype: tagByCreatedType.get("deliveryNotes"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentName: "Eingangsrechnungen",
|
||||||
|
function: "incomingInvoices" as const,
|
||||||
|
icon: "i-heroicons-inbox-arrow-down",
|
||||||
|
standardFiletype: tagByIncomingType.get("invoices"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentName: "Belege Bankeinzahlung",
|
||||||
|
function: "deposit" as const,
|
||||||
|
icon: "i-heroicons-banknotes",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const folder of yearFolders) {
|
||||||
|
const parent = rootFolderByName.get(folder.parentName)
|
||||||
|
if (!parent) continue
|
||||||
|
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: folders.id })
|
||||||
|
.from(folders)
|
||||||
|
.where(and(eq(folders.tenant, tenantId), eq(folders.parent, parent), eq(folders.year, currentYear)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(folders).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
name: String(currentYear),
|
||||||
|
parent,
|
||||||
|
function: folder.function,
|
||||||
|
year: currentYear,
|
||||||
|
icon: folder.icon,
|
||||||
|
standardFiletype: folder.standardFiletype,
|
||||||
|
standardFiletypeIsOptional: false,
|
||||||
|
isSystemUsed: true,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureTenantBaseData(server: FastifyInstance, tenantId: number, adminUserId: string) {
|
||||||
|
await ensureGlobalDefaults(server, adminUserId)
|
||||||
|
await ensureTenantFileDefaults(server, tenantId, adminUserId)
|
||||||
|
|
||||||
|
const [adminRole] = await server.db
|
||||||
|
.select({ id: authRoles.id })
|
||||||
|
.from(authRoles)
|
||||||
|
.where(and(eq(authRoles.tenant_id, tenantId), eq(authRoles.name, "Administrator")))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
let adminRoleId = adminRole?.id
|
||||||
|
if (!adminRoleId) {
|
||||||
|
const [createdRole] = await server.db
|
||||||
|
.insert(authRoles)
|
||||||
|
.values({
|
||||||
|
name: "Administrator",
|
||||||
|
description: "Vollzugriff für die Administration dieses Mandanten",
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_by: adminUserId,
|
||||||
|
})
|
||||||
|
.returning({ id: authRoles.id })
|
||||||
|
|
||||||
|
adminRoleId = createdRole.id
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const permission of adminPermissions) {
|
||||||
|
const existing = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authRolePermissions)
|
||||||
|
.where(and(eq(authRolePermissions.role_id, adminRoleId), eq(authRolePermissions.permission, permission)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(authRolePermissions).values({
|
||||||
|
role_id: adminRoleId,
|
||||||
|
permission,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authTenantUsers)
|
||||||
|
.where(and(eq(authTenantUsers.tenant_id, tenantId), eq(authTenantUsers.user_id, adminUserId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!membership.length) {
|
||||||
|
await server.db.insert(authTenantUsers).values({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: adminUserId,
|
||||||
|
created_by: adminUserId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleAssignment = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authUserRoles)
|
||||||
|
.where(and(
|
||||||
|
eq(authUserRoles.tenant_id, tenantId),
|
||||||
|
eq(authUserRoles.user_id, adminUserId),
|
||||||
|
eq(authUserRoles.role_id, adminRoleId),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!roleAssignment.length) {
|
||||||
|
await server.db.insert(authUserRoles).values({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: adminUserId,
|
||||||
|
role_id: adminRoleId,
|
||||||
|
created_by: adminUserId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.user_id, adminUserId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!profile.length) {
|
||||||
|
await server.db.insert(authProfiles).values({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: adminUserId,
|
||||||
|
first_name: process.env.FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME || "Admin",
|
||||||
|
last_name: process.env.FEDEO_BOOTSTRAP_ADMIN_LAST_NAME || "Benutzer",
|
||||||
|
email: process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase(),
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = await server.db
|
||||||
|
.select({ id: branches.id })
|
||||||
|
.from(branches)
|
||||||
|
.where(and(eq(branches.tenant, tenantId), eq(branches.name, "Hauptstandort")))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
let branchId = branch[0]?.id
|
||||||
|
if (!branchId) {
|
||||||
|
const [createdBranch] = await server.db.insert(branches).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Hauptstandort",
|
||||||
|
number: "001",
|
||||||
|
description: "Standardstandort",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: adminUserId,
|
||||||
|
}).returning({ id: branches.id })
|
||||||
|
branchId = createdBranch.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await server.db
|
||||||
|
.select({ id: teams.id })
|
||||||
|
.from(teams)
|
||||||
|
.where(and(eq(teams.tenant, tenantId), eq(teams.name, "Standardteam")))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!team.length) {
|
||||||
|
await server.db.insert(teams).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Standardteam",
|
||||||
|
description: "Automatisch angelegtes Standardteam",
|
||||||
|
branch: branchId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: adminUserId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProductCategory = await server.db
|
||||||
|
.select({ id: productcategories.id })
|
||||||
|
.from(productcategories)
|
||||||
|
.where(and(eq(productcategories.tenant, tenantId), eq(productcategories.name, "Standard")))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!defaultProductCategory.length) {
|
||||||
|
await server.db.insert(productcategories).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Standard",
|
||||||
|
description: "Standardkategorie",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: adminUserId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultServiceCategory = await server.db
|
||||||
|
.select({ id: servicecategories.id })
|
||||||
|
.from(servicecategories)
|
||||||
|
.where(and(eq(servicecategories.tenant, tenantId), eq(servicecategories.name, "Standard")))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!defaultServiceCategory.length) {
|
||||||
|
await server.db.insert(servicecategories).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
name: "Standard",
|
||||||
|
description: "Standardkategorie",
|
||||||
|
updated_at: new Date(),
|
||||||
|
updated_by: adminUserId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateDefaults = [
|
||||||
|
{ name: "Standard Einleitung", pos: "startText" as const, text: "<p>vielen Dank für Ihre Anfrage.</p>" },
|
||||||
|
{ name: "Standard Schluss", pos: "endText" as const, text: "<p>Mit freundlichen Grüßen</p>" },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const template of templateDefaults) {
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: texttemplates.id })
|
||||||
|
.from(texttemplates)
|
||||||
|
.where(and(eq(texttemplates.tenant, tenantId), eq(texttemplates.name, template.name)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(texttemplates).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
name: template.name,
|
||||||
|
text: template.text,
|
||||||
|
pos: template.pos,
|
||||||
|
default: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: adminUserId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBootstrap(server: FastifyInstance) {
|
||||||
|
const email = process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase()
|
||||||
|
const password = process.env.FEDEO_BOOTSTRAP_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
if (!email && !password) return
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new Error("FEDEO_BOOTSTRAP_ADMIN_EMAIL und FEDEO_BOOTSTRAP_ADMIN_PASSWORD müssen gemeinsam gesetzt sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUser] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.email, email))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
let adminUser = existingUser
|
||||||
|
if (!adminUser) {
|
||||||
|
const [createdUser] = await server.db.insert(authUsers).values({
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 10),
|
||||||
|
is_admin: true,
|
||||||
|
multiTenant: true,
|
||||||
|
must_change_password: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
adminUser = createdUser
|
||||||
|
console.log(`✅ Bootstrap-Admin angelegt: ${email}`)
|
||||||
|
} else if (!adminUser.is_admin) {
|
||||||
|
const [updatedUser] = await server.db.update(authUsers).set({
|
||||||
|
is_admin: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(authUsers.id, adminUser.id)).returning()
|
||||||
|
|
||||||
|
adminUser = updatedUser
|
||||||
|
console.log(`✅ Bootstrap-Adminrechte gesetzt: ${email}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantName = process.env.FEDEO_BOOTSTRAP_TENANT_NAME?.trim() || "FEDEO"
|
||||||
|
const tenantShort = process.env.FEDEO_BOOTSTRAP_TENANT_SHORT?.trim() || "FEDEO"
|
||||||
|
|
||||||
|
const [existingTenant] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(tenants)
|
||||||
|
.where(eq(tenants.short, tenantShort))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
let tenant = existingTenant
|
||||||
|
if (!tenant) {
|
||||||
|
const [createdTenant] = await server.db.insert(tenants).values({
|
||||||
|
name: tenantName,
|
||||||
|
short: tenantShort,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: adminUser.id,
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
tenant = createdTenant
|
||||||
|
console.log(`✅ Bootstrap-Mandant angelegt: ${tenant.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureTenantBaseData(server, tenant.id, adminUser.id)
|
||||||
|
console.log("✅ Bootstrap-Grunddaten geprüft")
|
||||||
|
|
||||||
|
if (process.env.FEDEO_BOOTSTRAP_MATRIX === "true") {
|
||||||
|
try {
|
||||||
|
const matrix = matrixService(server)
|
||||||
|
await matrix.provisionTenantRoom(adminUser.id, tenant.id, {
|
||||||
|
key: "allgemein",
|
||||||
|
name: "Allgemeiner Chat",
|
||||||
|
type: "general",
|
||||||
|
})
|
||||||
|
console.log("✅ Bootstrap-Matrix-Kommunikation geprüft")
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Bootstrap-Matrix-Kommunikation fehlgeschlagen:", err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
2387
backend/src/modules/matrix.service.ts
Normal file
2387
backend/src/modules/matrix.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,53 @@
|
|||||||
// services/notification.service.ts
|
import type { FastifyInstance } from "fastify"
|
||||||
import type { FastifyInstance } from 'fastify';
|
import webPush from "web-push"
|
||||||
import {secrets} from "../utils/secrets";
|
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||||
import { eq } from "drizzle-orm";
|
import {
|
||||||
import { notificationsEventTypes, notificationsItems } from "../../db/schema";
|
authUsers,
|
||||||
|
notificationMobilePushDevices,
|
||||||
|
notificationPushSubscriptions,
|
||||||
|
notificationsEventTypes,
|
||||||
|
notificationsItems,
|
||||||
|
notificationsPreferences,
|
||||||
|
notificationsPreferencesDefaults,
|
||||||
|
} from "../../db/schema"
|
||||||
|
import { secrets } from "../utils/secrets"
|
||||||
|
import { pushServerClient } from "./push-server.client"
|
||||||
|
|
||||||
export type NotificationStatus = 'queued' | 'sent' | 'failed';
|
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
|
||||||
|
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
|
||||||
|
|
||||||
export interface TriggerInput {
|
export interface TriggerInput {
|
||||||
tenantId: number;
|
tenantId: number
|
||||||
userId: string; // muss auf public.auth_users.id zeigen
|
userId?: string
|
||||||
eventType: string; // muss in notifications_event_types existieren
|
userIds?: string[]
|
||||||
title: string; // Betreff/Title
|
eventType: string
|
||||||
message: string; // Klartext-Inhalt
|
title: string
|
||||||
payload?: Record<string, unknown>;
|
message: string
|
||||||
|
payload?: Record<string, unknown>
|
||||||
|
channels?: NotificationChannel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushSubscriptionInput {
|
||||||
|
endpoint: string
|
||||||
|
keys: {
|
||||||
|
p256dh: string
|
||||||
|
auth: string
|
||||||
|
}
|
||||||
|
deviceLabel?: string
|
||||||
|
meta?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDirectoryInfo {
|
export interface UserDirectoryInfo {
|
||||||
email?: string;
|
email?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserDirectory = (server: FastifyInstance, userId: string, tenantId: number) => Promise<UserDirectoryInfo | null>;
|
export type UserDirectory = (
|
||||||
|
server: FastifyInstance,
|
||||||
|
userId: string,
|
||||||
|
tenantId: number
|
||||||
|
) => Promise<UserDirectoryInfo | null>
|
||||||
|
|
||||||
|
const DEFAULT_CHANNELS: NotificationChannel[] = ["inapp"]
|
||||||
|
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -27,99 +55,386 @@ export class NotificationService {
|
|||||||
private getUser: UserDirectory
|
private getUser: UserDirectory
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Löst eine E-Mail-Benachrichtigung aus:
|
|
||||||
* - Validiert den Event-Typ
|
|
||||||
* - Legt einen Datensatz in notifications_items an (status: queued)
|
|
||||||
* - Versendet E-Mail (FEDEO Branding)
|
|
||||||
* - Aktualisiert status/sent_at bzw. error
|
|
||||||
*/
|
|
||||||
async trigger(input: TriggerInput) {
|
async trigger(input: TriggerInput) {
|
||||||
const { tenantId, userId, eventType, title, message, payload } = input;
|
const tenantId = input.tenantId
|
||||||
|
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
|
||||||
|
|
||||||
// 1) Event-Typ prüfen (aktiv?)
|
if (!tenantId) throw new Error("tenantId fehlt")
|
||||||
const eventTypeRows = await this.server.db
|
if (!userIds.length) throw new Error("Keine Empfänger angegeben")
|
||||||
|
|
||||||
|
const eventType = await this.getActiveEventType(input.eventType)
|
||||||
|
const allowedChannels = this.normalizeChannels(eventType.allowedChannels)
|
||||||
|
const requestedChannels = input.channels?.length ? input.channels : allowedChannels
|
||||||
|
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
|
||||||
|
|
||||||
|
if (!channels.length) {
|
||||||
|
return { success: true, created: 0, delivered: 0, skipped: userIds.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const enabledChannels = await this.resolveEnabledChannels({
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
eventType: input.eventType,
|
||||||
|
channels,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const channel of enabledChannels) {
|
||||||
|
const itemRows = await this.server.db
|
||||||
|
.insert(notificationsItems)
|
||||||
|
.values({
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
eventType: input.eventType,
|
||||||
|
title: input.title,
|
||||||
|
message: input.message,
|
||||||
|
payload: input.payload ?? null,
|
||||||
|
channel,
|
||||||
|
status: "queued",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
const item = itemRows[0]
|
||||||
|
if (!item) continue
|
||||||
|
|
||||||
|
results.push(await this.deliver(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: results.every((result) => result.success),
|
||||||
|
created: results.length,
|
||||||
|
delivered: results.filter((result) => result.success).length,
|
||||||
|
failed: results.filter((result) => !result.success).length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForUser(tenantId: number, userId: string, limit = 50) {
|
||||||
|
return await this.server.db
|
||||||
|
.select()
|
||||||
|
.from(notificationsItems)
|
||||||
|
.where(and(
|
||||||
|
eq(notificationsItems.tenantId, tenantId),
|
||||||
|
eq(notificationsItems.userId, userId),
|
||||||
|
eq(notificationsItems.channel, "inapp")
|
||||||
|
))
|
||||||
|
.orderBy(desc(notificationsItems.createdAt))
|
||||||
|
.limit(Math.min(Math.max(limit, 1), 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(tenantId: number, userId: string, notificationId: string) {
|
||||||
|
const rows = await this.server.db
|
||||||
|
.update(notificationsItems)
|
||||||
|
.set({ readAt: new Date(), status: "read" })
|
||||||
|
.where(and(
|
||||||
|
eq(notificationsItems.id, notificationId),
|
||||||
|
eq(notificationsItems.tenantId, tenantId),
|
||||||
|
eq(notificationsItems.userId, userId)
|
||||||
|
))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerPushSubscription(
|
||||||
|
tenantId: number,
|
||||||
|
userId: string,
|
||||||
|
subscription: PushSubscriptionInput,
|
||||||
|
userAgent?: string
|
||||||
|
) {
|
||||||
|
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
|
||||||
|
throw new Error("Push-Subscription ist unvollständig")
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.server.db
|
||||||
|
.insert(notificationPushSubscriptions)
|
||||||
|
.values({
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: subscription.keys.p256dh,
|
||||||
|
auth: subscription.keys.auth,
|
||||||
|
userAgent,
|
||||||
|
deviceLabel: subscription.deviceLabel,
|
||||||
|
meta: subscription.meta ?? null,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
disabledAt: null,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: notificationPushSubscriptions.endpoint,
|
||||||
|
set: {
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
p256dh: subscription.keys.p256dh,
|
||||||
|
auth: subscription.keys.auth,
|
||||||
|
userAgent,
|
||||||
|
deviceLabel: subscription.deviceLabel,
|
||||||
|
meta: subscription.meta ?? null,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
disabledAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async disablePushSubscription(tenantId: number, userId: string, endpoint: string) {
|
||||||
|
await this.server.db
|
||||||
|
.update(notificationPushSubscriptions)
|
||||||
|
.set({ disabledAt: new Date() })
|
||||||
|
.where(and(
|
||||||
|
eq(notificationPushSubscriptions.tenantId, tenantId),
|
||||||
|
eq(notificationPushSubscriptions.userId, userId),
|
||||||
|
eq(notificationPushSubscriptions.endpoint, endpoint)
|
||||||
|
))
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublicPushConfig() {
|
||||||
|
return {
|
||||||
|
configured: Boolean(secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY),
|
||||||
|
publicKey: secrets.WEB_PUSH_PUBLIC_KEY || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getActiveEventType(eventType: string) {
|
||||||
|
const rows = await this.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(notificationsEventTypes)
|
.from(notificationsEventTypes)
|
||||||
.where(eq(notificationsEventTypes.eventKey, eventType))
|
.where(eq(notificationsEventTypes.eventKey, eventType))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
const eventTypeRow = eventTypeRows[0]
|
|
||||||
|
|
||||||
if (!eventTypeRow || eventTypeRow.isActive !== true) {
|
const row = rows[0]
|
||||||
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
|
if (!row || row.isActive !== true) {
|
||||||
|
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Zieladresse beschaffen
|
return row
|
||||||
const user = await this.getUser(this.server, userId, tenantId);
|
}
|
||||||
if (!user?.email) {
|
|
||||||
throw new Error(`Nutzer ${userId} hat keine E-Mail-Adresse`);
|
private normalizeChannels(value: unknown): NotificationChannel[] {
|
||||||
|
if (!Array.isArray(value)) return DEFAULT_CHANNELS
|
||||||
|
|
||||||
|
const valid = new Set(["inapp", "email", "push", "webhook", "sms"])
|
||||||
|
const channels = value.filter((channel): channel is NotificationChannel =>
|
||||||
|
typeof channel === "string" && valid.has(channel)
|
||||||
|
)
|
||||||
|
|
||||||
|
return channels.length ? channels : DEFAULT_CHANNELS
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveEnabledChannels(input: {
|
||||||
|
tenantId: number
|
||||||
|
userId: string
|
||||||
|
eventType: string
|
||||||
|
channels: NotificationChannel[]
|
||||||
|
}) {
|
||||||
|
const prefs = await this.server.db
|
||||||
|
.select()
|
||||||
|
.from(notificationsPreferences)
|
||||||
|
.where(and(
|
||||||
|
eq(notificationsPreferences.tenantId, input.tenantId),
|
||||||
|
eq(notificationsPreferences.userId, input.userId),
|
||||||
|
eq(notificationsPreferences.eventType, input.eventType),
|
||||||
|
inArray(notificationsPreferences.channel, input.channels)
|
||||||
|
))
|
||||||
|
|
||||||
|
const defaults = await this.server.db
|
||||||
|
.select()
|
||||||
|
.from(notificationsPreferencesDefaults)
|
||||||
|
.where(and(
|
||||||
|
eq(notificationsPreferencesDefaults.tenantId, input.tenantId),
|
||||||
|
eq(notificationsPreferencesDefaults.eventKey, input.eventType),
|
||||||
|
inArray(notificationsPreferencesDefaults.channel, input.channels)
|
||||||
|
))
|
||||||
|
|
||||||
|
return input.channels.filter((channel) => {
|
||||||
|
const userPref = prefs.find((pref) => pref.channel === channel)
|
||||||
|
if (userPref) return userPref.enabled
|
||||||
|
|
||||||
|
const defaultPref = defaults.find((pref) => pref.channel === channel)
|
||||||
|
if (defaultPref) return defaultPref.enabled
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliver(item: typeof notificationsItems.$inferSelect) {
|
||||||
|
if (item.channel === "inapp") {
|
||||||
|
await this.markSent(item.id)
|
||||||
|
return { success: true, id: item.id, channel: item.channel }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Notification anlegen (status: queued)
|
if (item.channel === "push") {
|
||||||
const insertedRows = await this.server.db
|
return await this.deliverPush(item)
|
||||||
.insert(notificationsItems)
|
|
||||||
.values({
|
|
||||||
tenantId,
|
|
||||||
userId,
|
|
||||||
eventType,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
payload: payload ?? null,
|
|
||||||
channel: 'email',
|
|
||||||
status: 'queued'
|
|
||||||
})
|
|
||||||
.returning({ id: notificationsItems.id })
|
|
||||||
const inserted = insertedRows[0]
|
|
||||||
|
|
||||||
if (!inserted) {
|
|
||||||
throw new Error("Fehler beim Einfügen der Notification");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) E-Mail versenden
|
if (item.channel === "email") {
|
||||||
|
return await this.deliverEmail(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.markFailed(item.id, `Kein Zusteller für Kanal ${item.channel}`)
|
||||||
|
return { success: false, id: item.id, channel: item.channel }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverPush(item: typeof notificationsItems.$inferSelect) {
|
||||||
|
const subscriptions = secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY
|
||||||
|
? await this.server.db
|
||||||
|
.select()
|
||||||
|
.from(notificationPushSubscriptions)
|
||||||
|
.where(and(
|
||||||
|
eq(notificationPushSubscriptions.tenantId, item.tenantId),
|
||||||
|
eq(notificationPushSubscriptions.userId, item.userId),
|
||||||
|
isNull(notificationPushSubscriptions.disabledAt)
|
||||||
|
))
|
||||||
|
: []
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
message: item.message,
|
||||||
|
payload: item.payload || {},
|
||||||
|
})
|
||||||
|
|
||||||
|
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({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: subscription.p256dh,
|
||||||
|
auth: subscription.auth,
|
||||||
|
},
|
||||||
|
}, payload)
|
||||||
|
|
||||||
|
delivered++
|
||||||
|
await this.server.db
|
||||||
|
.update(notificationPushSubscriptions)
|
||||||
|
.set({ lastSeenAt: new Date() })
|
||||||
|
.where(eq(notificationPushSubscriptions.id, subscription.id))
|
||||||
|
} catch (error: any) {
|
||||||
|
errors.push(error?.message || String(error))
|
||||||
|
|
||||||
|
if (error?.statusCode === 404 || error?.statusCode === 410) {
|
||||||
|
await this.server.db
|
||||||
|
.update(notificationPushSubscriptions)
|
||||||
|
.set({ disabledAt: new Date() })
|
||||||
|
.where(eq(notificationPushSubscriptions.id, subscription.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delivered > 0) {
|
||||||
|
await this.markSent(item.id)
|
||||||
|
return { success: true, id: item.id, channel: item.channel, delivered }
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.markFailed(item.id, errors.join("; ") || "Push konnte nicht zugestellt werden")
|
||||||
|
return { success: false, id: item.id, channel: item.channel }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverEmail(item: typeof notificationsItems.$inferSelect) {
|
||||||
try {
|
try {
|
||||||
await this.sendEmail(user.email, title, message);
|
const user = await this.getUser(this.server, item.userId, item.tenantId)
|
||||||
|
if (!user?.email) throw new Error(`Nutzer ${item.userId} hat keine E-Mail-Adresse`)
|
||||||
|
|
||||||
await this.server.db
|
await this.sendEmail(user.email, item.title, item.message)
|
||||||
.update(notificationsItems)
|
await this.markSent(item.id)
|
||||||
.set({ status: 'sent', sentAt: new Date() })
|
return { success: true, id: item.id, channel: item.channel }
|
||||||
.where(eq(notificationsItems.id, inserted.id));
|
} catch (error: any) {
|
||||||
|
await this.markFailed(item.id, error?.message || "E-Mail Versand fehlgeschlagen")
|
||||||
return { success: true, id: inserted.id };
|
this.server.log.error({ err: error, notificationId: item.id }, "E-Mail Versand fehlgeschlagen")
|
||||||
} catch (err: any) {
|
return { success: false, id: item.id, channel: item.channel }
|
||||||
await this.server.db
|
|
||||||
.update(notificationsItems)
|
|
||||||
.set({ status: 'failed', error: String(err?.message || err) })
|
|
||||||
.where(eq(notificationsItems.id, inserted.id));
|
|
||||||
|
|
||||||
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
|
|
||||||
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- private helpers ------------------------------------------------------
|
private async markSent(id: string) {
|
||||||
|
await this.server.db
|
||||||
|
.update(notificationsItems)
|
||||||
|
.set({ status: "sent", sentAt: new Date() })
|
||||||
|
.where(eq(notificationsItems.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markFailed(id: string, error: string) {
|
||||||
|
await this.server.db
|
||||||
|
.update(notificationsItems)
|
||||||
|
.set({ status: "failed", error })
|
||||||
|
.where(eq(notificationsItems.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
private async sendEmail(to: string, subject: string, message: string) {
|
private async sendEmail(to: string, subject: string, message: string) {
|
||||||
const nodemailer = await import('nodemailer');
|
const nodemailer = await import("nodemailer")
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: secrets.MAILER_SMTP_HOST,
|
host: secrets.MAILER_SMTP_HOST,
|
||||||
port: Number(secrets.MAILER_SMTP_PORT),
|
port: Number(secrets.MAILER_SMTP_PORT),
|
||||||
secure: secrets.MAILER_SMTP_SSL === 'true',
|
secure: secrets.MAILER_SMTP_SSL === "true",
|
||||||
auth: {
|
auth: {
|
||||||
user: secrets.MAILER_SMTP_USER,
|
user: secrets.MAILER_SMTP_USER,
|
||||||
pass: secrets.MAILER_SMTP_PASS
|
pass: secrets.MAILER_SMTP_PASS,
|
||||||
}
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const html = this.renderFedeoHtml(subject, message);
|
const html = this.renderFedeoHtml(subject, message)
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: secrets.MAILER_FROM,
|
from: secrets.MAILER_FROM,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
text: message,
|
text: message,
|
||||||
html
|
html,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderFedeoHtml(title: string, message: string) {
|
private renderFedeoHtml(title: string, message: string) {
|
||||||
@@ -133,18 +448,17 @@ export class NotificationService {
|
|||||||
<p style="font-size:12px;color:#666">Automatisch generiert von FEDEO</p>
|
<p style="font-size:12px;color:#666">Automatisch generiert von FEDEO</p>
|
||||||
</div>
|
</div>
|
||||||
</body></html>
|
</body></html>
|
||||||
`;
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
// simple escaping (ausreichend für unser Template)
|
|
||||||
private escapeHtml(s: string) {
|
private escapeHtml(s: string) {
|
||||||
return s
|
return s
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, '<')
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, '>');
|
.replace(/>/g, ">")
|
||||||
}
|
}
|
||||||
|
|
||||||
private nl2br(s: string) {
|
private nl2br(s: string) {
|
||||||
return s.replace(/\n/g, '<br/>');
|
return s.replace(/\n/g, "<br/>")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
contract: item.contract,
|
||||||
address: item.address,
|
address: item.address,
|
||||||
project: item.project,
|
project: item.project,
|
||||||
|
costcentre: item.costcentre,
|
||||||
documentDate: executionDate,
|
documentDate: executionDate,
|
||||||
deliveryDate: firstDate,
|
deliveryDate: firstDate,
|
||||||
deliveryDateEnd: lastDate,
|
deliveryDateEnd: lastDate,
|
||||||
|
|||||||
174
backend/src/modules/system-status.service.ts
Normal file
174
backend/src/modules/system-status.service.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { matrixService } from "./matrix.service"
|
||||||
|
|
||||||
|
type MetricSample = {
|
||||||
|
labels: Record<string, string>
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?|-?Inf|NaN)$/i
|
||||||
|
|
||||||
|
const nodeExporterUrl = () =>
|
||||||
|
(process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "")
|
||||||
|
|
||||||
|
const s3EndpointUrl = () =>
|
||||||
|
(process.env.S3_ENDPOINT || "").replace(/\/+$/, "")
|
||||||
|
|
||||||
|
const parseLabels = (value = "") => {
|
||||||
|
const labels: Record<string, string> = {}
|
||||||
|
const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = labelPattern.exec(value))) {
|
||||||
|
labels[match[1]] = match[2].replace(/\\"/g, "\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsePrometheusMetrics = (text: string) => {
|
||||||
|
const metrics = new Map<string, MetricSample[]>()
|
||||||
|
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (!line || line.startsWith("#")) continue
|
||||||
|
|
||||||
|
const match = line.match(metricLinePattern)
|
||||||
|
if (!match) continue
|
||||||
|
|
||||||
|
const value = Number(match[3])
|
||||||
|
if (!Number.isFinite(value)) continue
|
||||||
|
|
||||||
|
const samples = metrics.get(match[1]) || []
|
||||||
|
samples.push({
|
||||||
|
labels: parseLabels(match[2]),
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
metrics.set(match[1], samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMetricValue = (metrics: Map<string, MetricSample[]>, name: string) =>
|
||||||
|
metrics.get(name)?.[0]?.value ?? null
|
||||||
|
|
||||||
|
const findMetricValue = (
|
||||||
|
metrics: Map<string, MetricSample[]>,
|
||||||
|
name: string,
|
||||||
|
predicate: (sample: MetricSample) => boolean
|
||||||
|
) => metrics.get(name)?.find(predicate)?.value ?? null
|
||||||
|
|
||||||
|
const serviceState = (ok: boolean, detail?: Record<string, any>) => ({
|
||||||
|
ok,
|
||||||
|
status: ok ? "ok" : "error",
|
||||||
|
...detail,
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkHttp = async (url: string, timeoutMs = 3000) => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal })
|
||||||
|
return serviceState(response.ok, {
|
||||||
|
httpStatus: response.status,
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
return serviceState(false, {
|
||||||
|
url,
|
||||||
|
error: err?.message || "HTTP-Abfrage fehlgeschlagen",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||||
|
const checkedAt = new Date()
|
||||||
|
const nodeExporterMetricsUrl = `${nodeExporterUrl()}/metrics`
|
||||||
|
let nodeMetrics: Map<string, MetricSample[]> | null = null
|
||||||
|
let nodeExporterError: string | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(nodeExporterMetricsUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Node Exporter antwortet mit ${response.status}`)
|
||||||
|
}
|
||||||
|
nodeMetrics = parsePrometheusMetrics(await response.text())
|
||||||
|
} catch (err: any) {
|
||||||
|
nodeExporterError = err?.message || "Node Exporter nicht erreichbar"
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryTotal = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemTotal_bytes") : null
|
||||||
|
const memoryAvailable = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemAvailable_bytes") : null
|
||||||
|
const rootSize = nodeMetrics
|
||||||
|
? findMetricValue(nodeMetrics, "node_filesystem_size_bytes", (sample) => sample.labels.mountpoint === "/")
|
||||||
|
: null
|
||||||
|
const rootAvailable = nodeMetrics
|
||||||
|
? findMetricValue(nodeMetrics, "node_filesystem_avail_bytes", (sample) => sample.labels.mountpoint === "/")
|
||||||
|
: null
|
||||||
|
const bootTime = nodeMetrics ? firstMetricValue(nodeMetrics, "node_boot_time_seconds") : null
|
||||||
|
const cpuCount = nodeMetrics
|
||||||
|
? new Set((nodeMetrics.get("node_cpu_seconds_total") || [])
|
||||||
|
.filter((sample) => sample.labels.mode === "idle")
|
||||||
|
.map((sample) => sample.labels.cpu)).size
|
||||||
|
: null
|
||||||
|
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
|
||||||
|
|
||||||
|
const databaseCheck = await server.db.execute("SELECT NOW() as now")
|
||||||
|
const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({
|
||||||
|
reachable: false,
|
||||||
|
error: err?.message || "Matrix-Status nicht verfügbar",
|
||||||
|
}))
|
||||||
|
const minioUrl = s3EndpointUrl()
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkedAt: checkedAt.toISOString(),
|
||||||
|
backend: {
|
||||||
|
status: "ok",
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
nodeVersion: process.version,
|
||||||
|
environment: process.env.NODE_ENV || "development",
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
status: nodeMetrics ? "ok" : "unavailable",
|
||||||
|
nodeExporterUrl: nodeExporterMetricsUrl,
|
||||||
|
error: nodeExporterError,
|
||||||
|
hostname: uname?.nodename || null,
|
||||||
|
kernel: uname?.release || null,
|
||||||
|
cpuCount,
|
||||||
|
load: {
|
||||||
|
one: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load1") : null,
|
||||||
|
five: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load5") : null,
|
||||||
|
fifteen: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load15") : null,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
totalBytes: memoryTotal,
|
||||||
|
availableBytes: memoryAvailable,
|
||||||
|
usedBytes: memoryTotal !== null && memoryAvailable !== null ? memoryTotal - memoryAvailable : null,
|
||||||
|
usedPercent: memoryTotal ? Math.round(((memoryTotal - (memoryAvailable || 0)) / memoryTotal) * 1000) / 10 : null,
|
||||||
|
},
|
||||||
|
disk: {
|
||||||
|
rootTotalBytes: rootSize,
|
||||||
|
rootAvailableBytes: rootAvailable,
|
||||||
|
rootUsedBytes: rootSize !== null && rootAvailable !== null ? rootSize - rootAvailable : null,
|
||||||
|
rootUsedPercent: rootSize ? Math.round(((rootSize - (rootAvailable || 0)) / rootSize) * 1000) / 10 : null,
|
||||||
|
},
|
||||||
|
uptimeSeconds: bootTime ? Math.max(0, Math.round(Date.now() / 1000 - bootTime)) : null,
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
database: serviceState(true, {
|
||||||
|
checkedAt: String(databaseCheck.rows?.[0]?.now || checkedAt.toISOString()),
|
||||||
|
}),
|
||||||
|
nodeExporter: serviceState(Boolean(nodeMetrics), {
|
||||||
|
url: nodeExporterMetricsUrl,
|
||||||
|
error: nodeExporterError,
|
||||||
|
}),
|
||||||
|
matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record<string, any>),
|
||||||
|
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
|
||||||
|
error: "S3_ENDPOINT ist nicht gesetzt",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,19 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server.addHook("preHandler", async (req, reply) => {
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlPath = req.url.split("?")[0]
|
||||||
|
const queryToken = (req.query as any)?.downloadToken
|
||||||
|
const downloadToken =
|
||||||
|
typeof queryToken === "string"
|
||||||
|
&& urlPath.startsWith("/api/email/attachments/")
|
||||||
|
&& urlPath.endsWith("/download")
|
||||||
|
? queryToken
|
||||||
|
: null
|
||||||
|
|
||||||
// 1️⃣ Token aus Header oder Cookie lesen
|
// 1️⃣ Token aus Header oder Cookie lesen
|
||||||
const cookieToken = req.cookies?.token
|
const cookieToken = req.cookies?.token
|
||||||
const authHeader = req.headers.authorization
|
const authHeader = req.headers.authorization
|
||||||
@@ -74,7 +87,7 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
const token =
|
const token =
|
||||||
headerToken && headerToken.length > 10
|
headerToken && headerToken.length > 10
|
||||||
? headerToken
|
? headerToken
|
||||||
: cookieToken || null
|
: cookieToken || downloadToken || null
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return reply.code(401).send({ error: "Authentication required" })
|
return reply.code(401).send({ error: "Authentication required" })
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
import { generateRandomPassword, hashPassword } from "../utils/password";
|
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||||
import { sendMail } from "../utils/mailer";
|
import { sendMail } from "../utils/mailer";
|
||||||
|
import { ensureTenantBaseData } from "../modules/bootstrap.service";
|
||||||
|
import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport";
|
||||||
|
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||||
|
import { buildSystemStatus } from "../modules/system-status.service";
|
||||||
|
import { matrixService } from "../modules/matrix.service";
|
||||||
|
|
||||||
export default async function adminRoutes(server: FastifyInstance) {
|
export default async function adminRoutes(server: FastifyInstance) {
|
||||||
const deriveNameFromEmail = (email: string) => {
|
const deriveNameFromEmail = (email: string) => {
|
||||||
@@ -42,36 +47,42 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
name: "Rechnungen",
|
name: "Rechnungen",
|
||||||
color: "#16a34a",
|
color: "#16a34a",
|
||||||
createdDocumentType: "invoices",
|
createdDocumentType: "invoices",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Angebote",
|
name: "Angebote",
|
||||||
color: "#2563eb",
|
color: "#2563eb",
|
||||||
createdDocumentType: "quotes",
|
createdDocumentType: "quotes",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Auftragsbestätigungen",
|
name: "Auftragsbestätigungen",
|
||||||
color: "#7c3aed",
|
color: "#7c3aed",
|
||||||
createdDocumentType: "confirmationOrders",
|
createdDocumentType: "confirmationOrders",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Lieferscheine",
|
name: "Lieferscheine",
|
||||||
color: "#ea580c",
|
color: "#ea580c",
|
||||||
createdDocumentType: "deliveryNotes",
|
createdDocumentType: "deliveryNotes",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Eingangsrechnungen",
|
name: "Eingangsrechnungen",
|
||||||
color: "#dc2626",
|
color: "#dc2626",
|
||||||
incomingDocumentType: "invoices",
|
incomingDocumentType: "invoices",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Mahnungen",
|
name: "Mahnungen",
|
||||||
color: "#b91c1c",
|
color: "#b91c1c",
|
||||||
incomingDocumentType: "reminders",
|
incomingDocumentType: "reminders",
|
||||||
|
isSystemUsed: true,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.returning({
|
.returning({
|
||||||
@@ -243,6 +254,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
const [currentUser] = await server.db
|
const [currentUser] = await server.db
|
||||||
.select({
|
.select({
|
||||||
id: authUsers.id,
|
id: authUsers.id,
|
||||||
|
email: authUsers.email,
|
||||||
is_admin: authUsers.is_admin,
|
is_admin: authUsers.is_admin,
|
||||||
})
|
})
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
@@ -383,6 +395,21 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GET /admin/system-status
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/admin/system-status", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
return await buildSystemStatus(server);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/system-status:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// POST /admin/users
|
// POST /admin/users
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -825,6 +852,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await createTenantSeeds(createdTenant.id, currentUser.id);
|
await createTenantSeeds(createdTenant.id, currentUser.id);
|
||||||
|
await ensureTenantBaseData(server, createdTenant.id, currentUser.id);
|
||||||
|
|
||||||
return { tenant: createdTenant };
|
return { tenant: createdTenant };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -927,6 +955,115 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GET /admin/tenants/:tenant_id/export
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/admin/tenants/:tenant_id/export", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const { tenant_id } = req.params as { tenant_id: string };
|
||||||
|
const tenantId = Number(tenant_id);
|
||||||
|
if (!tenantId) {
|
||||||
|
return reply.code(400).send({ error: "tenant_id required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData = await buildTenantFullExport(server, tenantId);
|
||||||
|
const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId)
|
||||||
|
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
const filename = `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
|
||||||
|
reply.header("Content-Type", "application/json");
|
||||||
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
return reply.send(exportData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/tenants/:tenant_id/export:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// POST /admin/tenant-imports
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
|
||||||
|
const exportData = "format" in body ? body : body.exportData;
|
||||||
|
const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null;
|
||||||
|
|
||||||
|
if (!exportData) {
|
||||||
|
return reply.code(400).send({ error: "exportData required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await importTenantFullExport(server, exportData, { targetTenantId });
|
||||||
|
const fallbackName = deriveNameFromEmail(currentUser.email);
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.insert(authTenantUsers)
|
||||||
|
.values({
|
||||||
|
tenant_id: result.tenantId,
|
||||||
|
user_id: currentUser.id,
|
||||||
|
created_by: currentUser.id,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
const [existingAdminProfile] = await server.db
|
||||||
|
.select({ id: authProfiles.id })
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.tenant_id, result.tenantId),
|
||||||
|
eq(authProfiles.user_id, currentUser.id)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingAdminProfile) {
|
||||||
|
await server.db
|
||||||
|
.insert(authProfiles)
|
||||||
|
.values({
|
||||||
|
tenant_id: result.tenantId,
|
||||||
|
user_id: currentUser.id,
|
||||||
|
first_name: fallbackName.first_name,
|
||||||
|
last_name: fallbackName.last_name,
|
||||||
|
email: currentUser.email,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let matrixProvisioned = false;
|
||||||
|
let matrixProvisioningError: string | null = null;
|
||||||
|
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
|
||||||
|
try {
|
||||||
|
const matrix = matrixService(server);
|
||||||
|
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
|
||||||
|
key: "allgemein",
|
||||||
|
name: "Allgemeiner Chat",
|
||||||
|
type: "general",
|
||||||
|
});
|
||||||
|
matrixProvisioned = true;
|
||||||
|
} catch (err: any) {
|
||||||
|
matrixProvisioningError = err?.message || String(err);
|
||||||
|
req.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
matrixProvisioned,
|
||||||
|
matrixProvisioningError,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("ERROR /admin/tenant-imports:", err);
|
||||||
|
return reply.code(500).send({ error: err?.message || "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// PUT /admin/users/:user_id/access
|
// PUT /admin/users/:user_id/access
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
|||||||
id: tenants.id,
|
id: tenants.id,
|
||||||
name: tenants.name,
|
name: tenants.name,
|
||||||
short: tenants.short,
|
short: tenants.short,
|
||||||
|
calendarConfig: tenants.calendarConfig,
|
||||||
hasActiveLicense: tenants.hasActiveLicense,
|
hasActiveLicense: tenants.hasActiveLicense,
|
||||||
locked: tenants.locked,
|
locked: tenants.locked,
|
||||||
features: tenants.features,
|
features: tenants.features,
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
|
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
|
||||||
if (!body.date || !dayjs(body.date).isValid()) return reply.code(400).send({ error: "Bitte ein gültiges Buchungsdatum angeben." })
|
const bookingDate = body.date && dayjs(body.date).isValid() ? dayjs(body.date) : dayjs()
|
||||||
if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." })
|
if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." })
|
||||||
if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." })
|
if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." })
|
||||||
|
|
||||||
@@ -273,8 +273,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
|
if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
|
||||||
|
|
||||||
const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
|
const hasCounterInput = Boolean(body.counterType || body.counterId)
|
||||||
if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." })
|
const counterPayload = hasCounterInput
|
||||||
|
? buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
|
||||||
|
: null
|
||||||
|
if (hasCounterInput && !counterPayload) return reply.code(400).send({ error: "Bitte ein gültiges Gegenkonto auswählen." })
|
||||||
|
|
||||||
const signedAmount = body.direction === "income"
|
const signedAmount = body.direction === "income"
|
||||||
? Math.abs(Number(body.amount))
|
? Math.abs(Number(body.amount))
|
||||||
@@ -284,7 +287,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
const created = await server.db.transaction(async (tx) => {
|
const created = await server.db.transaction(async (tx) => {
|
||||||
const insertedStatements = await tx.insert(bankstatements).values({
|
const insertedStatements = await tx.insert(bankstatements).values({
|
||||||
account: cashbookId,
|
account: cashbookId,
|
||||||
date: dayjs(body.date).format("YYYY-MM-DD"),
|
date: bookingDate.format("YYYY-MM-DD"),
|
||||||
|
valueDate: bookingDate.format("YYYY-MM-DD"),
|
||||||
amount: signedAmount,
|
amount: signedAmount,
|
||||||
tenant: req.user.tenant_id,
|
tenant: req.user.tenant_id,
|
||||||
text: description,
|
text: description,
|
||||||
@@ -295,18 +299,20 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
}).returning()
|
}).returning()
|
||||||
|
|
||||||
const statement = insertedStatements[0]
|
const statement = insertedStatements[0]
|
||||||
const insertedAllocations = await tx.insert(statementallocations).values({
|
const insertedAllocations = counterPayload
|
||||||
bankstatement: statement.id,
|
? await tx.insert(statementallocations).values({
|
||||||
amount: signedAmount,
|
bankstatement: statement.id,
|
||||||
tenant: req.user.tenant_id,
|
amount: signedAmount,
|
||||||
description,
|
tenant: req.user.tenant_id,
|
||||||
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
|
description,
|
||||||
...counterPayload,
|
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
|
||||||
}).returning()
|
...counterPayload,
|
||||||
|
}).returning()
|
||||||
|
: []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statement,
|
statement,
|
||||||
allocation: insertedAllocations[0],
|
allocation: insertedAllocations[0] || null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -687,7 +693,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (matchesBankAccountId && matchesIban) {
|
if (matchesBankAccountId && matchesIban) {
|
||||||
score = 100
|
score = 100
|
||||||
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
|
reason = "IBAN und hinterlegte Bankverbindung stimmen überein"
|
||||||
} else if (matchesBankAccountId) {
|
} else if (matchesBankAccountId) {
|
||||||
score = 95
|
score = 95
|
||||||
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
||||||
@@ -699,7 +705,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
reason = "Name passt exakt zur Buchung"
|
reason = "Name passt exakt zur Buchung"
|
||||||
} else if (partialNameMatch) {
|
} else if (partialNameMatch) {
|
||||||
score = 45
|
score = 45
|
||||||
reason = "Name aehnelt der Buchung"
|
reason = "Name ähnelt der Buchung"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!score) continue
|
if (!score) continue
|
||||||
@@ -743,7 +749,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (matchesBankAccountId && matchesIban) {
|
if (matchesBankAccountId && matchesIban) {
|
||||||
score = 100
|
score = 100
|
||||||
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
|
reason = "IBAN und hinterlegte Bankverbindung stimmen überein"
|
||||||
} else if (matchesBankAccountId) {
|
} else if (matchesBankAccountId) {
|
||||||
score = 95
|
score = 95
|
||||||
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
||||||
@@ -755,7 +761,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
reason = "Name passt exakt zur Buchung"
|
reason = "Name passt exakt zur Buchung"
|
||||||
} else if (partialNameMatch) {
|
} else if (partialNameMatch) {
|
||||||
score = 45
|
score = 45
|
||||||
reason = "Name aehnelt der Buchung"
|
reason = "Name ähnelt der Buchung"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!score) continue
|
if (!score) continue
|
||||||
|
|||||||
1015
backend/src/routes/communication.ts
Normal file
1015
backend/src/routes/communication.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,72 @@
|
|||||||
import nodemailer from "nodemailer"
|
import nodemailer from "nodemailer"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { eq } from "drizzle-orm"
|
import { and, eq } from "drizzle-orm"
|
||||||
|
|
||||||
import { sendMailAsUser } from "../utils/emailengine"
|
|
||||||
import { encrypt, decrypt } from "../utils/crypt"
|
import { encrypt, decrypt } from "../utils/crypt"
|
||||||
import { userCredentials } from "../../db/schema"
|
import { userCredentials } from "../../db/schema"
|
||||||
// Pfad ggf. anpassen
|
import { emailSyncService } from "../modules/email/email.sync.service"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import MailComposer from "nodemailer/lib/mail-composer/index.js"
|
import MailComposer from "nodemailer/lib/mail-composer/index.js"
|
||||||
import { ImapFlow } from "imapflow"
|
import { ImapFlow } from "imapflow"
|
||||||
|
|
||||||
export default async function emailAsUserRoutes(server: FastifyInstance) {
|
export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||||
|
const emailSync = emailSyncService(server)
|
||||||
|
|
||||||
|
const encryptedValue = (value: unknown) => value ? decrypt(value as any) : null
|
||||||
|
|
||||||
|
const accountResponse = (row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
userId: row.userId,
|
||||||
|
tenantId: row.tenantId,
|
||||||
|
type: row.type,
|
||||||
|
email: encryptedValue(row.emailEncrypted),
|
||||||
|
smtpHost: encryptedValue(row.smtpHostEncrypted),
|
||||||
|
smtpPort: row.smtpPort ? Number(row.smtpPort) : null,
|
||||||
|
smtpSsl: row.smtpSsl,
|
||||||
|
imapHost: encryptedValue(row.imapHostEncrypted),
|
||||||
|
imapPort: row.imapPort ? Number(row.imapPort) : null,
|
||||||
|
imapSsl: row.imapSsl,
|
||||||
|
hasPassword: Boolean(row.passwordEncrypted),
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountCredentials = (row: any) => ({
|
||||||
|
...accountResponse(row),
|
||||||
|
password: encryptedValue(row.passwordEncrypted),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey]
|
||||||
|
|
||||||
|
const applyDownloadCorsHeaders = (req: any, reply: any) => {
|
||||||
|
const origin = req.headers.origin
|
||||||
|
if (
|
||||||
|
origin
|
||||||
|
&& (
|
||||||
|
/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin)
|
||||||
|
|| origin === "https://beta.fedeo.de"
|
||||||
|
|| origin === "https://app.fedeo.de"
|
||||||
|
|| origin === "capacitor://localhost"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
reply.header("Access-Control-Allow-Origin", origin)
|
||||||
|
reply.header("Access-Control-Allow-Credentials", "true")
|
||||||
|
reply.header("Vary", "Origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header("Access-Control-Expose-Headers", "Authorization, Content-Disposition, Content-Type, Content-Length")
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountWhere = (tenantId: number, userId: string, id?: string) => {
|
||||||
|
const conditions = [
|
||||||
|
eq(userCredentials.tenantId, tenantId),
|
||||||
|
eq(userCredentials.userId, userId),
|
||||||
|
eq(userCredentials.type, "mail"),
|
||||||
|
]
|
||||||
|
if (id) conditions.push(eq(userCredentials.id, id))
|
||||||
|
return and(...conditions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
@@ -28,34 +83,49 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
|||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
smtp_host: string
|
smtpHost?: string
|
||||||
smtp_port: number
|
smtpPort?: number
|
||||||
smtp_ssl: boolean
|
smtpSsl?: boolean
|
||||||
imap_host: string
|
imapHost?: string
|
||||||
imap_port: number
|
imapPort?: number
|
||||||
imap_ssl: boolean
|
imapSsl?: boolean
|
||||||
|
smtp_host?: string
|
||||||
|
smtp_port?: number
|
||||||
|
smtp_ssl?: boolean
|
||||||
|
imap_host?: string
|
||||||
|
imap_port?: number
|
||||||
|
imap_ssl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// UPDATE EXISTING
|
// UPDATE EXISTING
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
if (id) {
|
if (id) {
|
||||||
|
const rows = await server.db
|
||||||
|
.select({ id: userCredentials.id })
|
||||||
|
.from(userCredentials)
|
||||||
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) return reply.code(404).send({ error: "Account not found" })
|
||||||
|
|
||||||
const saveData = {
|
const saveData = {
|
||||||
emailEncrypted: body.email ? encrypt(body.email) : undefined,
|
emailEncrypted: body.email ? encrypt(body.email) : undefined,
|
||||||
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
|
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
|
||||||
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
|
smtpHostEncrypted: bodyValue(body, "smtpHost", "smtp_host") ? encrypt(bodyValue(body, "smtpHost", "smtp_host")) : undefined,
|
||||||
smtpPort: body.smtp_port,
|
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
|
||||||
smtpSsl: body.smtp_ssl,
|
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
|
||||||
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
|
imapHostEncrypted: bodyValue(body, "imapHost", "imap_host") ? encrypt(bodyValue(body, "imapHost", "imap_host")) : undefined,
|
||||||
imapPort: body.imap_port,
|
imapPort: bodyValue(body, "imapPort", "imap_port"),
|
||||||
imapSsl: body.imap_ssl,
|
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
|
||||||
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
await server.db
|
await server.db
|
||||||
.update(userCredentials)
|
.update(userCredentials)
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
.set(saveData)
|
.set(saveData)
|
||||||
.where(eq(userCredentials.id, id))
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
||||||
|
|
||||||
return reply.send({ success: true })
|
return reply.send({ success: true })
|
||||||
}
|
}
|
||||||
@@ -71,13 +141,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
|||||||
emailEncrypted: encrypt(body.email),
|
emailEncrypted: encrypt(body.email),
|
||||||
passwordEncrypted: encrypt(body.password),
|
passwordEncrypted: encrypt(body.password),
|
||||||
|
|
||||||
smtpHostEncrypted: encrypt(body.smtp_host),
|
smtpHostEncrypted: encrypt(bodyValue(body, "smtpHost", "smtp_host")),
|
||||||
smtpPort: body.smtp_port,
|
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
|
||||||
smtpSsl: body.smtp_ssl,
|
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
|
||||||
|
|
||||||
imapHostEncrypted: encrypt(body.imap_host),
|
imapHostEncrypted: encrypt(bodyValue(body, "imapHost", "imap_host")),
|
||||||
imapPort: body.imap_port,
|
imapPort: bodyValue(body, "imapPort", "imap_port"),
|
||||||
imapSsl: body.imap_ssl,
|
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
|
||||||
}
|
}
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
@@ -110,24 +180,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
|||||||
const rows = await server.db
|
const rows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(userCredentials)
|
.from(userCredentials)
|
||||||
.where(eq(userCredentials.id, id))
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
const row = rows[0]
|
const row = rows[0]
|
||||||
if (!row) return reply.code(404).send({ error: "Not found" })
|
if (!row) return reply.code(404).send({ error: "Not found" })
|
||||||
|
|
||||||
const returnData: any = {}
|
return reply.send(accountResponse(row))
|
||||||
|
|
||||||
Object.entries(row).forEach(([key, val]) => {
|
|
||||||
if (key.endsWith("Encrypted")) {
|
|
||||||
const cleanKey = key.replace("Encrypted", "")
|
|
||||||
// @ts-ignore
|
|
||||||
returnData[cleanKey] = decrypt(val as string)
|
|
||||||
} else {
|
|
||||||
returnData[key] = val
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send(returnData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -136,24 +195,9 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
|||||||
const rows = await server.db
|
const rows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(userCredentials)
|
.from(userCredentials)
|
||||||
.where(eq(userCredentials.tenantId, req.user.tenant_id))
|
.where(accountWhere(req.user.tenant_id, req.user.user_id))
|
||||||
|
|
||||||
const accounts = rows.map(row => {
|
return reply.send(rows.map(accountResponse))
|
||||||
const temp: any = {}
|
|
||||||
console.log(row)
|
|
||||||
Object.entries(row).forEach(([key, val]) => {
|
|
||||||
console.log(key,val)
|
|
||||||
if (key.endsWith("Encrypted") && val) {
|
|
||||||
// @ts-ignore
|
|
||||||
temp[key.replace("Encrypted", "")] = decrypt(val)
|
|
||||||
} else {
|
|
||||||
temp[key] = val
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return temp
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send(accounts)
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("GET /email/accounts error:", err)
|
console.error("GET /email/accounts error:", err)
|
||||||
@@ -183,21 +227,13 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
|||||||
const rows = await server.db
|
const rows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(userCredentials)
|
.from(userCredentials)
|
||||||
.where(eq(userCredentials.id, body.account))
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, body.account))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
const row = rows[0]
|
const row = rows[0]
|
||||||
if (!row) return reply.code(404).send({ error: "Account not found" })
|
if (!row) return reply.code(404).send({ error: "Account not found" })
|
||||||
|
|
||||||
const accountData: any = {}
|
const accountData = accountCredentials(row)
|
||||||
|
|
||||||
Object.entries(row).forEach(([key, val]) => {
|
|
||||||
if (key.endsWith("Encrypted") && val) {
|
|
||||||
// @ts-ignore
|
|
||||||
accountData[key.replace("Encrypted", "")] = decrypt(val as string)
|
|
||||||
} else {
|
|
||||||
accountData[key] = val
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// SEND EMAIL VIA SMTP
|
// SEND EMAIL VIA SMTP
|
||||||
@@ -243,14 +279,31 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
|||||||
const mail = new MailComposer(message)
|
const mail = new MailComposer(message)
|
||||||
const raw = await mail.compile().build()
|
const raw = await mail.compile().build()
|
||||||
|
|
||||||
|
let savedToSent = false
|
||||||
for await (const mailbox of await imap.list()) {
|
for await (const mailbox of await imap.list()) {
|
||||||
if (mailbox.specialUse === "\\Sent") {
|
if (mailbox.specialUse === "\\Sent") {
|
||||||
await imap.mailboxOpen(mailbox.path)
|
await imap.mailboxOpen(mailbox.path)
|
||||||
await imap.append(mailbox.path, raw, ["\\Seen"])
|
await imap.append(mailbox.path, raw, ["\\Seen"])
|
||||||
await imap.logout()
|
savedToSent = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!savedToSent) {
|
||||||
|
const sentFallbacks = ["Sent", "Gesendet", "INBOX.Sent"]
|
||||||
|
for (const path of sentFallbacks) {
|
||||||
|
try {
|
||||||
|
await imap.append(path, raw, ["\\Seen"])
|
||||||
|
savedToSent = true
|
||||||
|
break
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback wird nur genutzt, wenn der Ordner existiert.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await imap.logout()
|
||||||
|
|
||||||
return reply.send({ success: true })
|
return reply.send({ success: true })
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -259,4 +312,195 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.post("/email/accounts/:id/sync", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const body = (req.body || {}) as { mailbox?: string; limit?: number }
|
||||||
|
|
||||||
|
const result = await emailSync.syncAccount(
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
return reply.send({ success: true, ...result })
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mail Sync fehlgeschlagen" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/email/accounts/:id/mailboxes", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
return reply.send(await emailSync.listMailboxes(req.user.tenant_id, req.user.user_id, id))
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "Postfächer konnten nicht geladen werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/email/accounts/:id/messages", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const query = req.query as { mailbox?: string; limit?: string }
|
||||||
|
|
||||||
|
return reply.send(await emailSync.listMessages(
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
id,
|
||||||
|
query.mailbox || "INBOX",
|
||||||
|
Number(query.limit || 50),
|
||||||
|
))
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mails konnten nicht geladen werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/email/messages/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
|
||||||
|
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||||
|
|
||||||
|
return reply.send(message)
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht geladen werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/email/messages/:id/read", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const body = (req.body || {}) as { seen?: boolean }
|
||||||
|
const message = await emailSync.setMessageSeen(
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
id,
|
||||||
|
body.seen !== false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||||
|
|
||||||
|
return reply.send({ success: true, message })
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "Lesestatus konnte nicht synchronisiert werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/email/messages/:id/move", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const body = (req.body || {}) as { mailbox?: string }
|
||||||
|
|
||||||
|
if (!body.mailbox) {
|
||||||
|
return reply.code(400).send({ error: "Zielordner fehlt" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await emailSync.moveMessage(
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
id,
|
||||||
|
body.mailbox,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||||
|
return reply.send({ success: true, ...result })
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht verschoben werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/email/messages/:id/archive", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const result = await emailSync.archiveMessage(req.user.tenant_id, req.user.user_id, id)
|
||||||
|
|
||||||
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||||
|
return reply.send({ success: true, ...result })
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht archiviert werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.delete("/email/messages/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const result = await emailSync.deleteMessage(req.user.tenant_id, req.user.user_id, id)
|
||||||
|
|
||||||
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||||
|
return reply.send({ success: true })
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht gelöscht werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/email/attachments/:id/download", async (req, reply) => {
|
||||||
|
applyDownloadCorsHeaders(req, reply)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const attachment = await emailSync.getAttachmentContent(req.user.tenant_id, req.user.user_id, id)
|
||||||
|
|
||||||
|
if (!attachment) return reply.code(404).send({ error: "Anhang nicht gefunden" })
|
||||||
|
|
||||||
|
const buffer = Buffer.isBuffer(attachment.content)
|
||||||
|
? attachment.content
|
||||||
|
: Buffer.from(attachment.content)
|
||||||
|
const filename = attachment.filename.replace(/["\r\n]/g, "")
|
||||||
|
|
||||||
|
reply.header("Content-Type", attachment.contentType || "application/octet-stream")
|
||||||
|
reply.header("Content-Length", buffer.length)
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||||
|
return reply.send(buffer)
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "Anhang konnte nicht geladen werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,45 @@ const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDat
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createSepaExport = async (server: FastifyInstance, req: any, idsToExport: number[], creditorBankaccountId: number) => {
|
||||||
|
const exportData = await createSEPAExport(server, idsToExport, req.user.tenant_id, creditorBankaccountId)
|
||||||
|
|
||||||
|
const fileKey = `${req.user.tenant_id}/exports/SEPA_${dayjs().format("YYYY-MM-DD")}_${randomUUID()}.xml`
|
||||||
|
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: secrets.S3_BUCKET,
|
||||||
|
Key: fileKey,
|
||||||
|
Body: exportData.buffer,
|
||||||
|
ContentType: "application/xml",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const url = await getSignedUrl(
|
||||||
|
s3,
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: secrets.S3_BUCKET,
|
||||||
|
Key: fileKey,
|
||||||
|
}),
|
||||||
|
{ expiresIn: 60 * 60 * 24 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const inserted = await server.db
|
||||||
|
.insert(generatedexports)
|
||||||
|
.values({
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
startDate: exportData.startDate,
|
||||||
|
endDate: exportData.endDate,
|
||||||
|
validUntil: dayjs().add(24, "hours").toDate(),
|
||||||
|
filePath: fileKey,
|
||||||
|
url,
|
||||||
|
type: "sepa",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
console.log(inserted[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default async function exportRoutes(server: FastifyInstance) {
|
export default async function exportRoutes(server: FastifyInstance) {
|
||||||
//Export DATEV
|
//Export DATEV
|
||||||
@@ -94,17 +133,24 @@ export default async function exportRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
server.post("/exports/sepa", async (req, reply) => {
|
server.post("/exports/sepa", async (req, reply) => {
|
||||||
const { idsToExport } = req.body as {
|
const { idsToExport, creditorBankaccountId } = req.body as {
|
||||||
idsToExport: Array<number>
|
idsToExport: Array<number>
|
||||||
|
creditorBankaccountId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idsToExport?.length || !creditorBankaccountId) {
|
||||||
|
return reply.send({
|
||||||
|
success: false,
|
||||||
|
message: "Belege und Gläubigerkonto sind Pflichtfelder."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
reply.send({success:true})
|
reply.send({success:true})
|
||||||
|
|
||||||
setImmediate(async () => {
|
setImmediate(async () => {
|
||||||
try {
|
try {
|
||||||
await createSEPAExport(server, idsToExport, req.user.tenant_id)
|
await createSepaExport(server, req, idsToExport, creditorBankaccountId)
|
||||||
console.log("Job done ✅")
|
console.log("Job done ✅")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Job failed ❌", err)
|
console.error("Job failed ❌", err)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const columnMap: Record<string, any> = {
|
|||||||
customerspaces: historyitems.customerspace,
|
customerspaces: historyitems.customerspace,
|
||||||
customerinventoryitems: historyitems.customerinventoryitem,
|
customerinventoryitems: historyitems.customerinventoryitem,
|
||||||
memberrelations: historyitems.memberrelation,
|
memberrelations: historyitems.memberrelation,
|
||||||
|
outgoingsepamandates: historyitems.outgoingsepamandate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertFieldMap: Record<string, string> = {
|
const insertFieldMap: Record<string, string> = {
|
||||||
@@ -53,6 +54,7 @@ const insertFieldMap: Record<string, string> = {
|
|||||||
customerspaces: "customerspace",
|
customerspaces: "customerspace",
|
||||||
customerinventoryitems: "customerinventoryitem",
|
customerinventoryitems: "customerinventoryitem",
|
||||||
memberrelations: "memberrelation",
|
memberrelations: "memberrelation",
|
||||||
|
outgoingsepamandates: "outgoingsepamandate",
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseId = (value: string) => {
|
const parseId = (value: string) => {
|
||||||
|
|||||||
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,31 +1,196 @@
|
|||||||
// routes/notifications.routes.ts
|
import { FastifyInstance } from "fastify"
|
||||||
import { FastifyInstance } from 'fastify';
|
import { and, eq, isNull } from "drizzle-orm"
|
||||||
import { NotificationService, UserDirectory } from '../modules/notification.service';
|
import { authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||||
import { eq } from "drizzle-orm";
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
import { authUsers } from "../../db/schema";
|
import { pushServerClient } from "../modules/push-server.client"
|
||||||
|
|
||||||
// Beispiel: E-Mail aus eigener User-Tabelle laden
|
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||||
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
|
|
||||||
const rows = await server.db
|
const rows = await server.db
|
||||||
.select({ email: authUsers.email })
|
.select({ email: authUsers.email })
|
||||||
.from(authUsers)
|
.from(authUsers)
|
||||||
.where(eq(authUsers.id, userId))
|
.where(eq(authUsers.id, userId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
const data = rows[0]
|
const data = rows[0]
|
||||||
if (!data) return null;
|
if (!data) return null
|
||||||
return { email: data.email };
|
return { email: data.email }
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const requireTenant = (tenantId: number | null) => {
|
||||||
|
if (!tenantId) throw new Error("Kein aktiver Mandant")
|
||||||
|
return tenantId
|
||||||
|
}
|
||||||
|
|
||||||
export default async function notificationsRoutes(server: FastifyInstance) {
|
export default async function notificationsRoutes(server: FastifyInstance) {
|
||||||
const svc = new NotificationService(server, getUserDirectory);
|
const svc = new NotificationService(server, getUserDirectory)
|
||||||
|
|
||||||
server.post('/notifications/trigger', async (req, reply) => {
|
server.get("/notifications", async (req) => {
|
||||||
try {
|
const limit = Number((req.query as { limit?: string })?.limit || 50)
|
||||||
const res = await svc.trigger(req.body as any);
|
return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit)
|
||||||
reply.send(res);
|
})
|
||||||
} catch (err: any) {
|
|
||||||
server.log.error(err);
|
server.post("/notifications/:id/read", async (req, reply) => {
|
||||||
reply.code(500).send({ error: err.message });
|
const params = req.params as { id: string }
|
||||||
|
const item = await svc.markRead(requireTenant(req.user.tenant_id), req.user.user_id, params.id)
|
||||||
|
if (!item) return reply.code(404).send({ error: "Benachrichtigung nicht gefunden" })
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/notifications/push/config", async () => {
|
||||||
|
return svc.getPublicPushConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/notifications/push/subscribe", async (req) => {
|
||||||
|
const tenantId = requireTenant(req.user.tenant_id)
|
||||||
|
const userAgent = req.headers["user-agent"]
|
||||||
|
const subscription = await svc.registerPushSubscription(
|
||||||
|
tenantId,
|
||||||
|
req.user.user_id,
|
||||||
|
req.body as any,
|
||||||
|
Array.isArray(userAgent) ? userAgent.join(" ") : userAgent
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
id: subscription?.id,
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
|
server.delete("/notifications/push/subscribe", async (req) => {
|
||||||
|
const body = (req.body || {}) as { endpoint?: string }
|
||||||
|
if (!body.endpoint) throw new Error("endpoint fehlt")
|
||||||
|
return await svc.disablePushSubscription(requireTenant(req.user.tenant_id), req.user.user_id, body.endpoint)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/notifications/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),
|
||||||
|
userId: req.user.user_id,
|
||||||
|
eventType: "system.test_push",
|
||||||
|
title: "FEDEO Desktop Push ist aktiv",
|
||||||
|
message: "Diese Testbenachrichtigung wurde von FEDEO selbst zugestellt.",
|
||||||
|
payload: {
|
||||||
|
link: "/",
|
||||||
|
icon: "/favicon.ico",
|
||||||
|
},
|
||||||
|
channels: ["inapp", "push"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/notifications/trigger", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as any
|
||||||
|
const tenantId = body.tenantId || req.user.tenant_id
|
||||||
|
const res = await svc.trigger({
|
||||||
|
...body,
|
||||||
|
tenantId: requireTenant(tenantId),
|
||||||
|
})
|
||||||
|
reply.send(res)
|
||||||
|
} catch (err: any) {
|
||||||
|
server.log.error(err)
|
||||||
|
reply.code(500).send({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewToken(token: string) {
|
||||||
|
if (token.length <= 14) return token
|
||||||
|
return `${token.slice(0, 6)}...${token.slice(-6)}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
resolveTenantTeamIds,
|
resolveTenantTeamIds,
|
||||||
syncProfileTeams,
|
syncProfileTeams,
|
||||||
} from "../utils/profileTeams";
|
} from "../utils/profileTeams";
|
||||||
|
import {
|
||||||
|
enrichProfileWithCalendarSubscription,
|
||||||
|
generateProfileCalendarSubscriptionToken,
|
||||||
|
} from "../utils/calendarSubscription";
|
||||||
|
|
||||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
@@ -38,7 +42,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(404).send({ error: "User not found or not in tenant" });
|
return reply.code(404).send({ error: "User not found or not in tenant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return profile;
|
return enrichProfileWithCalendarSubscription(profile);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("GET /profiles/:id ERROR:", error);
|
console.error("GET /profiles/:id ERROR:", error);
|
||||||
@@ -50,10 +54,11 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
const cleaned: any = { ...body }
|
const cleaned: any = { ...body }
|
||||||
|
|
||||||
// ❌ Systemfelder entfernen
|
// ❌ Systemfelder entfernen
|
||||||
const forbidden = [
|
const forbidden = [
|
||||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||||
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
||||||
"branch"
|
"branch", "calendar_subscription_token",
|
||||||
|
"calendar_subscription_path", "calendar_subscription_url"
|
||||||
]
|
]
|
||||||
forbidden.forEach(f => delete cleaned[f])
|
forbidden.forEach(f => delete cleaned[f])
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
const [profile] = profileWithBranches
|
const [profile] = profileWithBranches
|
||||||
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||||
: [null]
|
: [null]
|
||||||
return profile || updated[0]
|
return enrichProfileWithCalendarSubscription(profile || updated[0])
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("PUT /profiles/:id ERROR:", err)
|
console.error("PUT /profiles/:id ERROR:", err)
|
||||||
@@ -159,4 +164,31 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(500).send({ error: "Internal Server Error" })
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.post("/profiles/:id/calendar-subscription-token", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const updatedProfile = await generateProfileCalendarSubscriptionToken(server, id, tenantId)
|
||||||
|
|
||||||
|
if (!updatedProfile) {
|
||||||
|
return reply.code(404).send({ error: "User not found or not in tenant" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
|
||||||
|
const [profile] = profileWithBranches
|
||||||
|
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||||
|
: [updatedProfile]
|
||||||
|
|
||||||
|
return enrichProfileWithCalendarSubscription(profile)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /profiles/:id/calendar-subscription-token ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,32 @@
|
|||||||
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||||
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
|
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
|
||||||
|
import { buildProfileCalendarSubscriptionFeed, loadProfileByCalendarSubscriptionToken } from '../../utils/calendarSubscription';
|
||||||
|
|
||||||
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
||||||
|
server.get("/api/public/calendar/subscriptions/:token.ics", async (req, reply) => {
|
||||||
|
const { token } = req.params as { token: string }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await loadProfileByCalendarSubscriptionToken(server, token)
|
||||||
|
|
||||||
|
if (!profile || !profile.active) {
|
||||||
|
return reply.code(404).send({ error: "Kalender-Abo nicht gefunden" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const icsFeed = await buildProfileCalendarSubscriptionFeed(server, profile)
|
||||||
|
|
||||||
|
reply.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
reply.header("Content-Disposition", `inline; filename="fedeo-${profile.id}.ics"`)
|
||||||
|
reply.header("Cache-Control", "private, max-age=300")
|
||||||
|
|
||||||
|
return reply.send(icsFeed)
|
||||||
|
} catch (error: any) {
|
||||||
|
server.log.error(error)
|
||||||
|
return reply.code(500).send({ error: "Interner Server Fehler" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.get("/workflows/context/:token", async (req, reply) => {
|
server.get("/workflows/context/:token", async (req, reply) => {
|
||||||
const { token } = req.params as { token: string };
|
const { token } = req.params as { token: string };
|
||||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||||
@@ -49,4 +73,4 @@ export default async function publiclinksNonAuthenticatedRoutes(server: FastifyI
|
|||||||
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
|
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
import { authProfiles, costcentres } from "../../../db/schema";
|
import { authProfiles, costcentres, customers, entitybankaccounts } from "../../../db/schema";
|
||||||
import { resourceConfig } from "../../utils/resource.config";
|
import { resourceConfig } from "../../utils/resource.config";
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||||
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
||||||
@@ -21,6 +21,9 @@ import { decrypt, encrypt } from "../../utils/crypt";
|
|||||||
|
|
||||||
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
|
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
|
||||||
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
|
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
|
||||||
|
const OUTGOING_SEPA_MANDATE_STATUSES = new Set(["Entwurf", "Aktiv", "Widerrufen", "Abgelaufen"])
|
||||||
|
const OUTGOING_SEPA_MANDATE_TYPES = new Set(["CORE", "B2B"])
|
||||||
|
const OUTGOING_SEPA_SEQUENCE_TYPES = new Set(["RCUR", "OOFF", "FRST", "FNAL"])
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
|
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
|
||||||
@@ -227,6 +230,53 @@ function isDateLikeField(key: string) {
|
|||||||
return /(^|_|-)date($|_|-)/i.test(key)
|
return /(^|_|-)date($|_|-)/i.test(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDateValue(value: any) {
|
||||||
|
if (value instanceof Date) return !Number.isNaN(value.getTime())
|
||||||
|
if (typeof value !== "string") return false
|
||||||
|
const normalized = value.trim()
|
||||||
|
if (/^\d+$/.test(normalized)) return false
|
||||||
|
return !Number.isNaN(new Date(normalized).getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCreatedDocumentPayload(payload: Record<string, any>) {
|
||||||
|
const numberRelationFields = [
|
||||||
|
"customer",
|
||||||
|
"contact",
|
||||||
|
"project",
|
||||||
|
"createddocument",
|
||||||
|
"letterhead",
|
||||||
|
"plant",
|
||||||
|
"contract",
|
||||||
|
"outgoingsepamandate",
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const field of numberRelationFields) {
|
||||||
|
const value = payload[field]
|
||||||
|
if (value instanceof Date || (typeof value === "string" && isDateValue(value))) {
|
||||||
|
payload[field] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialexecution = payload.serialexecution
|
||||||
|
if (serialexecution === undefined || serialexecution === null || serialexecution === "") return payload
|
||||||
|
|
||||||
|
if (isDateValue(serialexecution)) {
|
||||||
|
payload.serialexecution = null
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof serialexecution === "string") {
|
||||||
|
const normalized = serialexecution.trim()
|
||||||
|
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)
|
||||||
|
if (!isUuid && isDateValue(normalized)) {
|
||||||
|
payload.serialexecution = null
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMemberPayload(payload: Record<string, any>) {
|
function normalizeMemberPayload(payload: Record<string, any>) {
|
||||||
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
|
const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
|
||||||
const normalized = {
|
const normalized = {
|
||||||
@@ -321,16 +371,28 @@ function maskIban(iban: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decryptEntityBankAccount(row: Record<string, any>) {
|
function decryptEntityBankAccount(row: Record<string, any>) {
|
||||||
const iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
|
let iban = null
|
||||||
const bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
|
let bic = null
|
||||||
const bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null
|
let bankName = null
|
||||||
|
let decryptError = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null
|
||||||
|
bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null
|
||||||
|
bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null
|
||||||
|
} catch (err: any) {
|
||||||
|
decryptError = err?.message || "Bankverbindung konnte nicht entschlüsselt werden."
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
iban,
|
iban,
|
||||||
bic,
|
bic,
|
||||||
bankName,
|
bankName,
|
||||||
displayLabel: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(),
|
decryptError,
|
||||||
|
displayLabel: decryptError
|
||||||
|
? `Bankverbindung nicht lesbar${row.description ? ` (${row.description})` : ""}`
|
||||||
|
: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +427,65 @@ function prepareEntityBankAccountPayload(payload: Record<string, any>, requireAl
|
|||||||
return { data: result }
|
return { data: result }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateOutgoingSepaMandatePayload(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
existing: Record<string, any> | null = null
|
||||||
|
) {
|
||||||
|
const customerId = Number(payload.customer ?? existing?.customer)
|
||||||
|
const bankaccountId = Number(payload.bankaccount ?? existing?.bankaccount)
|
||||||
|
|
||||||
|
if (!customerId || !bankaccountId) {
|
||||||
|
return "Kunde und Bankverbindung sind Pflichtfelder."
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = payload.status ?? existing?.status ?? "Entwurf"
|
||||||
|
if (!OUTGOING_SEPA_MANDATE_STATUSES.has(status)) {
|
||||||
|
return "Ungültiger Mandatsstatus."
|
||||||
|
}
|
||||||
|
|
||||||
|
const mandateType = payload.mandateType ?? existing?.mandateType ?? "CORE"
|
||||||
|
if (!OUTGOING_SEPA_MANDATE_TYPES.has(mandateType)) {
|
||||||
|
return "Ungültiger Mandatstyp."
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequenceType = payload.sequenceType ?? existing?.sequenceType ?? "RCUR"
|
||||||
|
if (!OUTGOING_SEPA_SEQUENCE_TYPES.has(sequenceType)) {
|
||||||
|
return "Ungültige Mandatssequenz."
|
||||||
|
}
|
||||||
|
|
||||||
|
const [customer] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return "Kunde nicht gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bankaccount] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(entitybankaccounts)
|
||||||
|
.where(and(eq(entitybankaccounts.id, bankaccountId), eq(entitybankaccounts.tenant, tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!bankaccount) {
|
||||||
|
return "Bankverbindung nicht gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedBankAccountIds = Array.isArray((customer.infoData as any)?.bankAccountIds)
|
||||||
|
? (customer.infoData as any).bankAccountIds.map((id: any) => Number(id))
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!assignedBankAccountIds.includes(bankaccountId)) {
|
||||||
|
return "Die Bankverbindung ist dem ausgewählten Kunden nicht zugeordnet."
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export default async function resourceRoutes(server: FastifyInstance) {
|
export default async function resourceRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -796,6 +917,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "outgoingsepamandates") {
|
||||||
|
const validationError = await validateOutgoingSepaMandatePayload(server, req.user.tenant_id, createData)
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === "createddocuments") {
|
||||||
|
createData = normalizeCreatedDocumentPayload(createData)
|
||||||
|
}
|
||||||
|
|
||||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
||||||
@@ -804,11 +936,31 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
||||||
Object.keys(createData).forEach((key) => {
|
Object.keys(createData).forEach((key) => {
|
||||||
if (key.toLowerCase().includes("date") && key !== "deliveryDateType") createData[key] = normalizeDate(createData[key])
|
const value = createData[key]
|
||||||
|
const shouldNormalize =
|
||||||
|
isDateLikeField(key) &&
|
||||||
|
value !== null &&
|
||||||
|
value !== undefined &&
|
||||||
|
(typeof value === "string" || typeof value === "number" || value instanceof Date)
|
||||||
|
|
||||||
|
if (shouldNormalize) {
|
||||||
|
createData[key] = normalizeDate(value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const [created] = await server.db.insert(table).values(createData).returning()
|
const [created] = await server.db.insert(table).values(createData).returning()
|
||||||
|
|
||||||
|
if (resource === "outgoingsepamandates" && created?.defaultMandate) {
|
||||||
|
await server.db
|
||||||
|
.update(table)
|
||||||
|
.set({ defaultMandate: false })
|
||||||
|
.where(and(
|
||||||
|
eq(table.tenant, req.user.tenant_id),
|
||||||
|
eq(table.customer, created.customer),
|
||||||
|
sql`${table.id} <> ${created.id}`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if (["products", "services", "hourrates"].includes(resource)) {
|
if (["products", "services", "hourrates"].includes(resource)) {
|
||||||
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
|
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
|
||||||
}
|
}
|
||||||
@@ -876,6 +1028,14 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
delete data.updatedBy; delete data.updatedAt;
|
delete data.updatedBy; delete data.updatedAt;
|
||||||
|
|
||||||
|
if (resource === "filetags") {
|
||||||
|
delete data.isSystemUsed
|
||||||
|
|
||||||
|
if (oldRecord.isSystemUsed && data.archived === true) {
|
||||||
|
return reply.code(400).send({ error: "System-Dateitypen können nicht archiviert werden" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (portalCustomerId) {
|
if (portalCustomerId) {
|
||||||
data = {
|
data = {
|
||||||
...sanitizePortalCustomerUpdate(data),
|
...sanitizePortalCustomerUpdate(data),
|
||||||
@@ -917,6 +1077,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "outgoingsepamandates") {
|
||||||
|
const validationError = await validateOutgoingSepaMandatePayload(server, tenantId, data, oldRecord)
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === "createddocuments") {
|
||||||
|
data = normalizeCreatedDocumentPayload(data)
|
||||||
|
}
|
||||||
|
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
const value = data[key]
|
const value = data[key]
|
||||||
const shouldNormalize =
|
const shouldNormalize =
|
||||||
@@ -934,6 +1105,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
|
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
|
||||||
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
|
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
|
||||||
|
|
||||||
|
if (resource === "outgoingsepamandates" && updated?.defaultMandate) {
|
||||||
|
await server.db
|
||||||
|
.update(table)
|
||||||
|
.set({ defaultMandate: false })
|
||||||
|
.where(and(
|
||||||
|
eq(table.tenant, tenantId),
|
||||||
|
eq(table.customer, updated.customer),
|
||||||
|
sql`${table.id} <> ${updated.id}`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if (["products", "services", "hourrates"].includes(resource)) {
|
if (["products", "services", "hourrates"].includes(resource)) {
|
||||||
await recalculateServicePricesForTenant(server, tenantId, userId);
|
await recalculateServicePricesForTenant(server, tenantId, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
// GET CURRENT TENANT
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
server.get("/tenant", async (req) => {
|
server.get("/tenant", async (req, reply) => {
|
||||||
|
if (req.user?.tenant_id) {
|
||||||
|
const tenantRows = await server.db
|
||||||
|
.select()
|
||||||
|
.from(tenants)
|
||||||
|
.where(eq(tenants.id, Number(req.user.tenant_id)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!tenantRows.length) {
|
||||||
|
return reply.code(404).send({ error: "Tenant not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenantRows[0]
|
||||||
|
}
|
||||||
|
|
||||||
if (req.tenant) {
|
if (req.tenant) {
|
||||||
return {
|
return {
|
||||||
message: `Hallo vom Tenant ${req.tenant?.name}`,
|
message: `Hallo vom Tenant ${req.tenant?.name}`,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user