Compare commits
168 Commits
uichange
...
817d0e814b
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4e49dd18a1 | |||
| 252021acee | |||
| eae321b364 | |||
| 5a2682c835 | |||
| 34f537238e | |||
| 64df33f0fa | |||
| 94ab3350ec | |||
| aa162dcad3 | |||
| c42e57494a | |||
| 582af62fcb | |||
| 743c0e8772 | |||
| d4c39d7d44 | |||
| e60188f043 | |||
| ca4f1ba1c0 | |||
| 5fe823f52a | |||
| 1969610130 | |||
| 2bf52b35fe | |||
| f01881a6ce | |||
| a185c6eb11 | |||
| a8450fc0c6 | |||
| 0f5275b870 | |||
| 4f37811dcc | |||
| d7eced3e77 | |||
| 6a5c1e844d | |||
| 5dc44e571f | |||
| 2b1a9a456b | |||
| bf5d7aaed2 | |||
| e166248c0d | |||
| cba4ea52e8 | |||
| 0f14f7ac3d | |||
| 2d26cedaa3 | |||
| d5aed2140e | |||
| cfc5efb556 | |||
| 898a5459fa | |||
| 3b7bcb7940 | |||
| 2aaff0088e | |||
| e9bbc196f7 | |||
| 20818beb3a | |||
| 6aa69cb68b | |||
| a021d3d15c | |||
| bb61caed6d | |||
| d3ab03da7e | |||
| 5869f88c1a | |||
| 50c76b67c7 | |||
| f4edcc2d44 | |||
| 35ef3a7cf8 | |||
| 4783971000 | |||
| c085b1e4d5 | |||
| 46b08b29b9 | |||
| 5cc41f9a2d | |||
| edec670ee0 | |||
| 41e5a4021b | |||
| 9c608cbf71 | |||
| 543952dbf8 | |||
| 2f7819e309 | |||
| 7799cbce80 | |||
| 0284ea8726 | |||
| 743bf0660c | |||
| df4b591be4 | |||
| 86e0743cbb | |||
| aaf91ea15e | |||
| cb71e9d294 | |||
| 75148b2718 | |||
| 81b4eee1e8 | |||
| 0fbda27609 | |||
| 3562d55a12 | |||
| 6224a25c38 | |||
| 63b1c563c1 | |||
| 76f86e87c1 | |||
| 8c458f4953 | |||
| d704e343fc | |||
| 4882da0d35 | |||
| 1908a6441d | |||
| a4735818fb | |||
| 4fb3d3c8a0 | |||
| 30dc99e4e0 | |||
| 9fea18b215 | |||
| 75c15c14c4 | |||
| b27b00f59c | |||
| 1637d4bd91 | |||
| 8114a8c645 | |||
| 0b7d20d946 | |||
| 849e24092e | |||
| 6fcaf3f65c | |||
| dce0046e63 | |||
| 02b5769049 | |||
| f125617af0 | |||
| d9e5df07bf | |||
| 7996c746c3 | |||
| f679eb3624 | |||
| 7ad44544cf | |||
| 669bcd93ab | |||
| aee45e29fd | |||
| 42e0d7b35e | |||
| f6c9875320 | |||
| 05f3b678c4 | |||
| eb718021fd | |||
| 01b4d0f973 | |||
| c29494dc0d | |||
| 809a37a410 | |||
| 232e3f3260 | |||
| b2657f5d52 | |||
| cee0e1fa7d | |||
| 7dea2de7f3 | |||
| 4db753d34a | |||
| e0e99ba6f5 | |||
| ace2213cc4 | |||
| 7e6c5cc189 | |||
| 7c644c941a | |||
| 11a242d70d | |||
| 9f665fc3b8 |
103
.env.example
Normal file
103
.env.example
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# FEDEO Selfhosting
|
||||||
|
DOMAIN=app.example.com
|
||||||
|
CONTACT_EMAIL=admin@deine-domain.de
|
||||||
|
|
||||||
|
DB_NAME=fedeo
|
||||||
|
DB_USER=fedeo
|
||||||
|
DB_PASSWORD=change-this-db-password
|
||||||
|
DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo
|
||||||
|
|
||||||
|
MINIO_ROOT_USER=fedeo-minio
|
||||||
|
MINIO_ROOT_PASSWORD=change-this-minio-password
|
||||||
|
MINIO_BUCKET=fedeo
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3100
|
||||||
|
FEDEO_RUN_MIGRATIONS=true
|
||||||
|
COOKIE_SECRET=change-this-cookie-secret
|
||||||
|
JWT_SECRET=change-this-jwt-secret
|
||||||
|
ENCRYPTION_KEY=change-this-encryption-key
|
||||||
|
|
||||||
|
MAILER_SMTP_HOST=smtp.example.com
|
||||||
|
MAILER_SMTP_PORT=587
|
||||||
|
MAILER_SMTP_SSL=false
|
||||||
|
MAILER_SMTP_USER=mailer@example.com
|
||||||
|
MAILER_SMTP_PASS=change-this-mail-password
|
||||||
|
MAILER_FROM=FEDEO <no-reply@example.com>
|
||||||
|
|
||||||
|
# Desktop Push per Web Push. Schlüssel können mit
|
||||||
|
# `npx web-push generate-vapid-keys` erzeugt werden.
|
||||||
|
WEB_PUSH_PUBLIC_KEY=replace-this-web-push-public-key
|
||||||
|
WEB_PUSH_PRIVATE_KEY=replace-this-web-push-private-key
|
||||||
|
WEB_PUSH_SUBJECT=mailto:admin@example.com
|
||||||
|
|
||||||
|
S3_ENDPOINT=http://minio:9000
|
||||||
|
S3_REGION=eu-central-1
|
||||||
|
S3_ACCESS_KEY=fedeo-minio
|
||||||
|
S3_SECRET_KEY=change-this-minio-password
|
||||||
|
S3_BUCKET=fedeo
|
||||||
|
|
||||||
|
M2M_API_KEY=change-this-m2m-key
|
||||||
|
API_BASE_URL=https://app.example.com/backend
|
||||||
|
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
||||||
|
GOCARDLESS_SECRET_ID=replace-this
|
||||||
|
GOCARDLESS_SECRET_KEY=replace-this
|
||||||
|
|
||||||
|
DOKUBOX_IMAP_HOST=imap.example.com
|
||||||
|
DOKUBOX_IMAP_PORT=993
|
||||||
|
DOKUBOX_IMAP_SECURE=true
|
||||||
|
DOKUBOX_IMAP_USER=dokubox@example.com
|
||||||
|
DOKUBOX_IMAP_PASSWORD=change-this-imap-password
|
||||||
|
|
||||||
|
OPENAI_API_KEY=replace-this
|
||||||
|
STIRLING_API_KEY=replace-this
|
||||||
|
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
||||||
|
|
||||||
|
# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
|
||||||
|
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
||||||
|
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
||||||
|
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
||||||
|
|
||||||
|
# FEDEO Matrix-Kommunikation
|
||||||
|
#
|
||||||
|
# Diese Werte werden von docker-compose.yml gelesen, wenn das Profil "matrix"
|
||||||
|
# genutzt wird. Für produktive Systeme müssen alle Geheimnisse ersetzt werden.
|
||||||
|
|
||||||
|
MATRIX_SERVER_NAME=fedeo.de
|
||||||
|
MATRIX_HOMESERVER_HOST=matrix.fedeo.de
|
||||||
|
MATRIX_RTC_HOST=call.fedeo.de
|
||||||
|
MATRIX_TURN_HOST=turn.fedeo.de
|
||||||
|
|
||||||
|
MATRIX_POSTGRES_DB=synapse
|
||||||
|
MATRIX_POSTGRES_USER=synapse
|
||||||
|
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
|
||||||
|
|
||||||
|
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
|
||||||
|
|
||||||
|
LIVEKIT_KEY=fedeo-livekit
|
||||||
|
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
|
||||||
|
|
||||||
|
# Lokale Matrix-Entwicklung
|
||||||
|
MATRIX_DEV_SYNAPSE_PORT=8008
|
||||||
|
MATRIX_DEV_ELEMENT_PORT=8080
|
||||||
|
MATRIX_DEV_RTC_JWT_PORT=8081
|
||||||
|
MATRIX_DEV_LIVEKIT_PORT=7880
|
||||||
|
MATRIX_DEV_LIVEKIT_TCP_PORT=7881
|
||||||
|
MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000
|
||||||
|
MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100
|
||||||
|
MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1
|
||||||
|
MATRIX_DEV_TURN_PORT=3478
|
||||||
|
MATRIX_DEV_TURN_MIN_PORT=49160
|
||||||
|
MATRIX_DEV_TURN_MAX_PORT=49200
|
||||||
|
|
||||||
|
# Backend-Integration gegen den lokalen Matrix-Stack
|
||||||
|
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||||
|
MATRIX_RTC_JWT_URL=http://localhost:8081
|
||||||
|
MATRIX_LIVEKIT_URL=ws://localhost:7880
|
||||||
|
MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
|
||||||
|
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
|
||||||
|
NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Build and Push Docker Images
|
name: Build and Push Docker Images
|
||||||
run-name: Build Backend & Frontend by @${{ github.actor }}
|
run-name: Build Backend, Frontend & Docs by @${{ github.actor }}
|
||||||
|
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
@@ -8,12 +8,38 @@ env:
|
|||||||
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
|
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
|
||||||
# Beispiel: gitea.deine-domain.de
|
# Beispiel: gitea.deine-domain.de
|
||||||
REGISTRY_HOST: git.federspiel.tech
|
REGISTRY_HOST: git.federspiel.tech
|
||||||
# Der Name des Repos (z.B. user/repo)
|
# Der Name des Repos (z.B. user/repo).
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
|
||||||
|
IMAGE_NAME: flfeders/fedeo
|
||||||
ACTOR: flfeders
|
ACTOR: flfeders
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# verify-docs-sync:
|
||||||
|
# runs-on: ubuntu-latest
|
||||||
|
# steps:
|
||||||
|
# - name: Check out repository code
|
||||||
|
# uses: actions/checkout@v3
|
||||||
|
#
|
||||||
|
# - name: Prüfe Node-Version
|
||||||
|
# uses: actions/setup-node@v4
|
||||||
|
# with:
|
||||||
|
# node-version: 20
|
||||||
|
#
|
||||||
|
# - name: Synchronisiere Funktionsdokumentation
|
||||||
|
# run: node docs/scripts/sync-funktionsdoku.mjs
|
||||||
|
#
|
||||||
|
# - name: Breche ab, wenn Doku nicht aktuell committed ist
|
||||||
|
# run: |
|
||||||
|
# if [ -n "$(git status --porcelain docs/)" ]; then
|
||||||
|
# echo "Die generierte Dokumentation ist nicht aktuell."
|
||||||
|
# echo "Bitte lokal ausführen: node docs/scripts/sync-funktionsdoku.mjs"
|
||||||
|
# echo "Danach die Änderungen committen."
|
||||||
|
# git status --short docs/
|
||||||
|
# exit 1
|
||||||
|
# fi
|
||||||
|
|
||||||
build-backend:
|
build-backend:
|
||||||
|
#needs: verify-docs-sync
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
@@ -46,6 +72,7 @@ jobs:
|
|||||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
|
#needs: verify-docs-sync
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
@@ -74,4 +101,37 @@ jobs:
|
|||||||
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||||
|
|
||||||
|
build-docs:
|
||||||
|
needs: verify-docs-sync
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_HOST }}
|
||||||
|
username: ${{ env.ACTOR }}
|
||||||
|
password: ${{ vars.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docs
|
||||||
|
id: meta-docs
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/docs
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
|
- name: Build and push Docs
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docs-site/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta-docs.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-docs.outputs.labels }}
|
||||||
|
|||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
|
||||||
|
# Lokale Runtime-Daten und generierte Konfigurationen
|
||||||
|
matrix/postgres/
|
||||||
|
matrix/synapse/
|
||||||
|
matrix/dev/postgres/
|
||||||
|
matrix/dev/synapse/
|
||||||
57
README.md
57
README.md
@@ -89,7 +89,7 @@ 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 direkt aus einem geklonten Checkout dieses Repositories, weil die Selfhost-Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
|
||||||
|
|
||||||
Beispiel:
|
Beispiel:
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ Die Verzeichnisstruktur sollte dann mindestens so aussehen:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
/opt/fedeo/
|
/opt/fedeo/
|
||||||
docker-compose.yml
|
docker-compose.selfhost.yml
|
||||||
.env
|
.env
|
||||||
backend/
|
backend/
|
||||||
frontend/
|
frontend/
|
||||||
@@ -124,13 +124,21 @@ 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.
|
||||||
|
|
||||||
## 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
|
||||||
@@ -176,11 +184,22 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Vollstandiges Docker Compose mit optionaler S3-MinIO-Option
|
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
|
||||||
|
|
||||||
Hinweis: Der Stack unten startet MinIO standardmassig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
## Docker Compose mit optionaler S3-MinIO-Option
|
||||||
|
|
||||||
|
Die Selfhost-Konfiguration liegt in `docker-compose.selfhost.yml`. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
||||||
|
|
||||||
|
Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -316,6 +335,7 @@ 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
|
||||||
@@ -337,13 +357,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 +395,22 @@ Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit M
|
|||||||
Im Deploy-Verzeichnis:
|
Im Deploy-Verzeichnis:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose build
|
docker compose -f docker-compose.selfhost.yml build
|
||||||
docker compose up -d
|
docker compose -f docker-compose.selfhost.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach Status prufen:
|
Danach Status prufen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose -f docker-compose.selfhost.yml ps
|
||||||
docker compose logs -f traefik
|
docker compose -f docker-compose.selfhost.yml logs -f traefik
|
||||||
docker compose logs -f backend
|
docker compose -f docker-compose.selfhost.yml logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn du Migrationen manuell ausführen möchtest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.selfhost.yml run --rm backend npm run migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
## Funktionsprufung
|
## Funktionsprufung
|
||||||
@@ -398,16 +427,20 @@ 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
|
git pull
|
||||||
docker compose build
|
docker compose -f docker-compose.selfhost.yml build
|
||||||
docker compose up -d
|
docker compose -f docker-compose.selfhost.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden.
|
||||||
|
|
||||||
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
|
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
|
||||||
|
|
||||||
## Backup-Empfehlung
|
## Backup-Empfehlung
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import {secrets} from "../src/utils/secrets";
|
|||||||
|
|
||||||
console.log("[DB INIT] 1. Suche Connection String...");
|
console.log("[DB INIT] 1. Suche Connection String...");
|
||||||
|
|
||||||
|
const fallbackConnectionString = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
|
||||||
|
|
||||||
// Checken woher die URL kommt
|
// Checken woher die URL kommt
|
||||||
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL;
|
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
|
||||||
if (connectionString) {
|
if (process.env.DATABASE_URL) {
|
||||||
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
|
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
|
||||||
|
} else if (secrets.DATABASE_URL) {
|
||||||
|
console.log("[DB INIT] -> Gefunden in secrets.DATABASE_URL");
|
||||||
|
} else if (connectionString) {
|
||||||
|
console.log("[DB INIT] -> Nutze Fallback aus dem Projekt");
|
||||||
} else {
|
} else {
|
||||||
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
|
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
|
||||||
}
|
}
|
||||||
@@ -24,4 +30,4 @@ pool.query('SELECT NOW()')
|
|||||||
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
|
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
|
||||||
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
|
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
|
||||||
|
|
||||||
export const db = drizzle(pool, { schema });
|
export const db = drizzle(pool, { schema });
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
37
backend/db/migrations/0024_tenant_branches.sql
Normal file
37
backend/db/migrations/0024_tenant_branches.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE "branches" (
|
||||||
|
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"tenant" bigint NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"number" text,
|
||||||
|
"description" text,
|
||||||
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone,
|
||||||
|
"updated_by" uuid
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "branches" ADD CONSTRAINT "branches_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "branches" ADD CONSTRAINT "branches_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "costcentres" ADD COLUMN "branch" bigint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profiles" ADD COLUMN "branch_id" bigint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profiles" ADD CONSTRAINT "auth_profiles_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "auth_profile_branches" (
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"profile_id" uuid NOT NULL,
|
||||||
|
"branch_id" bigint NOT NULL,
|
||||||
|
"created_by" uuid,
|
||||||
|
CONSTRAINT "auth_profile_branches_profile_id_branch_id_pk" PRIMARY KEY("profile_id","branch_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "statementallocations"
|
||||||
|
ADD COLUMN "booking_mode" text DEFAULT 'expense' NOT NULL,
|
||||||
|
ADD COLUMN "depreciation_months" integer,
|
||||||
|
ADD COLUMN "depreciation_start_date" text,
|
||||||
|
ADD COLUMN "depreciation_label" text,
|
||||||
|
ADD COLUMN "depreciation_group" text;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "statementallocations"
|
||||||
|
ADD COLUMN "depreciation_method" text,
|
||||||
|
ADD COLUMN "residual_value" double precision;
|
||||||
2
backend/db/migrations/0027_product_supplier_link.sql
Normal file
2
backend/db/migrations/0027_product_supplier_link.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "products"
|
||||||
|
ADD COLUMN "supplierLink" text;
|
||||||
31
backend/db/migrations/0028_teams.sql
Normal file
31
backend/db/migrations/0028_teams.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
CREATE TABLE "teams" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "teams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"tenant" bigint NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"branch" bigint,
|
||||||
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone,
|
||||||
|
"updated_by" uuid
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "auth_profile_teams" (
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"profile_id" uuid NOT NULL,
|
||||||
|
"team_id" bigint NOT NULL,
|
||||||
|
"created_by" uuid,
|
||||||
|
CONSTRAINT "auth_profile_teams_profile_id_team_id_pk" PRIMARY KEY("profile_id","team_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "teams" ADD CONSTRAINT "teams_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "teams" ADD CONSTRAINT "teams_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "teams" ADD CONSTRAINT "teams_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
1
backend/db/migrations/0029_events_quick.sql
Normal file
1
backend/db/migrations/0029_events_quick.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "events" ADD COLUMN "quick" boolean DEFAULT false NOT NULL;
|
||||||
10
backend/db/migrations/0030_manual_statementallocations.sql
Normal file
10
backend/db/migrations/0030_manual_statementallocations.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE "statementallocations" ALTER COLUMN "bs_id" DROP NOT NULL;
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "manual_booking_date" text;
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "contra_account" bigint;
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "contra_ownaccount" uuid;
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "contra_customer" bigint;
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "contra_vendor" bigint;
|
||||||
|
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_account_accounts_id_fk" FOREIGN KEY ("contra_account") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_ownaccount_ownaccounts_id_fk" FOREIGN KEY ("contra_ownaccount") REFERENCES "public"."ownaccounts"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_customer_customers_id_fk" FOREIGN KEY ("contra_customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_vendor_vendors_id_fk" FOREIGN KEY ("contra_vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "datev_tax_key" text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "manual_invoice_side" text;
|
||||||
2
backend/db/migrations/0033_costcentres_parent.sql
Normal file
2
backend/db/migrations/0033_costcentres_parent.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid;
|
||||||
|
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;
|
||||||
2
backend/db/migrations/0034_profile_availability_note.sql
Normal file
2
backend/db/migrations/0034_profile_availability_note.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "auth_profiles"
|
||||||
|
ADD COLUMN IF NOT EXISTS "availability_note" text;
|
||||||
3
backend/db/migrations/0035_contract_history.sql
Normal file
3
backend/db/migrations/0035_contract_history.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "historyitems" ADD COLUMN "contract" bigint;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_contract_contracts_id_fk" FOREIGN KEY ("contract") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
1
backend/db/migrations/0036_allowed_contracttypes.sql
Normal file
1
backend/db/migrations/0036_allowed_contracttypes.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "contracts" ADD COLUMN "allowedContracttypes" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||||
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");
|
||||||
@@ -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;
|
||||||
11813
backend/db/migrations/meta/0025_snapshot.json
Normal file
11813
backend/db/migrations/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -138,30 +138,135 @@
|
|||||||
{
|
{
|
||||||
"idx": 19,
|
"idx": 19,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
|
"when": 1773489600000,
|
||||||
|
"tag": "0019_custom_surcharge_percentage_decimal",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
"when": 1773572400000,
|
"when": 1773572400000,
|
||||||
"tag": "0020_file_extracted_text",
|
"tag": "0020_file_extracted_text",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 20,
|
"idx": 21,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773835200000,
|
"when": 1773835200000,
|
||||||
"tag": "0021_admin_user_flag",
|
"tag": "0021_admin_user_flag",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 21,
|
"idx": 22,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773925200000,
|
"when": 1773925200000,
|
||||||
"tag": "0022_task_dependencies",
|
"tag": "0022_task_dependencies",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 22,
|
"idx": 23,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1774080000000,
|
"when": 1774080000000,
|
||||||
"tag": "0023_tax_evaluation_period",
|
"tag": "0023_tax_evaluation_period",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774393200000,
|
||||||
|
"tag": "0024_tenant_branches",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774393201000,
|
||||||
|
"tag": "0025_statementallocation_depreciation",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774393202000,
|
||||||
|
"tag": "0026_statementallocation_depreciation_method",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774602000000,
|
||||||
|
"tag": "0027_product_supplier_link",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776124800000,
|
||||||
|
"tag": "0028_teams",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776211200000,
|
||||||
|
"tag": "0029_events_quick",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776297600000,
|
||||||
|
"tag": "0030_manual_statementallocations",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776298200000,
|
||||||
|
"tag": "0031_manual_statementallocations_tax_key",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776298800000,
|
||||||
|
"tag": "0032_manual_statementallocations_invoice_side",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777003200000,
|
||||||
|
"tag": "0033_costcentres_parent",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778191200000,
|
||||||
|
"tag": "0035_contract_history",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778194800000,
|
||||||
|
"tag": "0036_allowed_contracttypes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778840100000,
|
||||||
|
"tag": "0037_outgoing_sepa_mandates",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 37,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778840200000,
|
||||||
|
"tag": "0034_profile_availability_note",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
30
backend/db/schema/auth_profile_branches.ts
Normal file
30
backend/db/schema/auth_profile_branches.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { authProfiles } from "./auth_profiles"
|
||||||
|
import { branches } from "./branches"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const authProfileBranches = pgTable(
|
||||||
|
"auth_profile_branches",
|
||||||
|
{
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
profile_id: uuid("profile_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authProfiles.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
branch_id: bigint("branch_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => branches.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
created_by: uuid("created_by").references(() => authUsers.id),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
primaryKey: [table.profile_id, table.branch_id],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AuthProfileBranch = typeof authProfileBranches.$inferSelect
|
||||||
|
export type NewAuthProfileBranch = typeof authProfileBranches.$inferInsert
|
||||||
30
backend/db/schema/auth_profile_teams.ts
Normal file
30
backend/db/schema/auth_profile_teams.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { authProfiles } from "./auth_profiles"
|
||||||
|
import { teams } from "./teams"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const authProfileTeams = pgTable(
|
||||||
|
"auth_profile_teams",
|
||||||
|
{
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
profile_id: uuid("profile_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authProfiles.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
team_id: bigint("team_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => teams.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
created_by: uuid("created_by").references(() => authUsers.id),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
primaryKey: [table.profile_id, table.team_id],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AuthProfileTeam = typeof authProfileTeams.$inferSelect
|
||||||
|
export type NewAuthProfileTeam = typeof authProfileTeams.$inferInsert
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
|
import { branches } from "./branches"
|
||||||
|
|
||||||
export const authProfiles = pgTable("auth_profiles", {
|
export const authProfiles = pgTable("auth_profiles", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", {
|
|||||||
|
|
||||||
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
|
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
|
||||||
|
|
||||||
|
branch_id: bigint("branch_id", { mode: "number" }).references(() => branches.id),
|
||||||
|
|
||||||
created_at: timestamp("created_at", { withTimezone: true })
|
created_at: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
@@ -71,6 +74,7 @@ export const authProfiles = pgTable("auth_profiles", {
|
|||||||
contract_type: text("contract_type"),
|
contract_type: text("contract_type"),
|
||||||
position: text("position"),
|
position: text("position"),
|
||||||
qualification: text("qualification"),
|
qualification: text("qualification"),
|
||||||
|
availability_note: text("availability_note"),
|
||||||
|
|
||||||
address_street: text("address_street"),
|
address_street: text("address_street"),
|
||||||
address_zip: text("address_zip"),
|
address_zip: text("address_zip"),
|
||||||
|
|||||||
37
backend/db/schema/branches.ts
Normal file
37
backend/db/schema/branches.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
bigint,
|
||||||
|
timestamp,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const branches = pgTable("branches", {
|
||||||
|
id: bigint("id", { mode: "number" })
|
||||||
|
.primaryKey()
|
||||||
|
.generatedByDefaultAsIdentity(),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id),
|
||||||
|
|
||||||
|
name: text("name").notNull(),
|
||||||
|
number: text("number"),
|
||||||
|
description: text("description"),
|
||||||
|
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Branch = typeof branches.$inferSelect
|
||||||
|
export type NewBranch = typeof branches.$inferInsert
|
||||||
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",
|
||||||
@@ -52,6 +53,7 @@ export const contracts = pgTable(
|
|||||||
contracttype: bigint("contracttype", { mode: "number" }).references(
|
contracttype: bigint("contracttype", { mode: "number" }).references(
|
||||||
() => contracttypes.id
|
() => contracttypes.id
|
||||||
),
|
),
|
||||||
|
allowedContracttypes: jsonb("allowedContracttypes").notNull().default([]),
|
||||||
|
|
||||||
bankingIban: text("bankingIban"),
|
bankingIban: text("bankingIban"),
|
||||||
bankingBIC: text("bankingBIC"),
|
bankingBIC: text("bankingBIC"),
|
||||||
@@ -59,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"),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { inventoryitems } from "./inventoryitems"
|
|||||||
import { projects } from "./projects"
|
import { projects } from "./projects"
|
||||||
import { vehicles } from "./vehicles"
|
import { vehicles } from "./vehicles"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
|
import { branches } from "./branches"
|
||||||
|
|
||||||
export const costcentres = pgTable("costcentres", {
|
export const costcentres = pgTable("costcentres", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -28,10 +29,14 @@ export const costcentres = pgTable("costcentres", {
|
|||||||
number: text("number").notNull(),
|
number: text("number").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
|
||||||
|
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
|
||||||
|
|
||||||
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
|
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
|
||||||
|
|
||||||
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
||||||
|
|
||||||
|
branch: bigint("branch", { mode: "number" }).references(() => branches.id),
|
||||||
|
|
||||||
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
|
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
|
||||||
() => inventoryitems.id
|
() => inventoryitems.id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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"
|
||||||
|
|
||||||
export const createddocuments = pgTable("createddocuments", {
|
export const createddocuments = pgTable("createddocuments", {
|
||||||
id: bigint("id", { mode: "number" })
|
id: bigint("id", { mode: "number" })
|
||||||
@@ -118,6 +119,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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const events = pgTable(
|
|||||||
endDate: timestamp("endDate", { withTimezone: true }),
|
endDate: timestamp("endDate", { withTimezone: true }),
|
||||||
|
|
||||||
eventtype: text("eventtype").default("Umsetzung"),
|
eventtype: text("eventtype").default("Umsetzung"),
|
||||||
|
quick: boolean("quick").notNull().default(false),
|
||||||
|
|
||||||
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
|
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import { inventoryitemgroups } from "./inventoryitemgroups"
|
|||||||
import { authUsers } from "./auth_users"
|
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 { outgoingsepamandates } from "./outgoingsepamandates";
|
||||||
|
|
||||||
export const historyitems = pgTable("historyitems", {
|
export const historyitems = pgTable("historyitems", {
|
||||||
id: bigint("id", { mode: "number" })
|
id: bigint("id", { mode: "number" })
|
||||||
@@ -52,6 +54,11 @@ export const historyitems = pgTable("historyitems", {
|
|||||||
{ onDelete: "cascade" }
|
{ onDelete: "cascade" }
|
||||||
),
|
),
|
||||||
|
|
||||||
|
contract: bigint("contract", { mode: "number" }).references(
|
||||||
|
() => contracts.id,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
|
||||||
tenant: bigint("tenant", { mode: "number" })
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id),
|
.references(() => tenants.id),
|
||||||
@@ -108,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(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export * from "./accounts"
|
export * from "./accounts"
|
||||||
export * from "./auth_profiles"
|
export * from "./auth_profiles"
|
||||||
|
export * from "./auth_profile_branches"
|
||||||
|
export * from "./auth_profile_teams"
|
||||||
export * from "./auth_role_permisssions"
|
export * from "./auth_role_permisssions"
|
||||||
export * from "./auth_roles"
|
export * from "./auth_roles"
|
||||||
export * from "./auth_tenant_users"
|
export * from "./auth_tenant_users"
|
||||||
@@ -8,9 +10,11 @@ export * from "./auth_users"
|
|||||||
export * from "./bankaccounts"
|
export * from "./bankaccounts"
|
||||||
export * from "./bankrequisitions"
|
export * from "./bankrequisitions"
|
||||||
export * from "./bankstatements"
|
export * from "./bankstatements"
|
||||||
|
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"
|
||||||
@@ -53,7 +57,9 @@ export * from "./notifications_event_types"
|
|||||||
export * from "./notifications_items"
|
export * from "./notifications_items"
|
||||||
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"
|
||||||
@@ -67,6 +73,7 @@ export * from "./staff_time_entry_connects"
|
|||||||
export * from "./staff_zeitstromtimestamps"
|
export * from "./staff_zeitstromtimestamps"
|
||||||
export * from "./statementallocations"
|
export * from "./statementallocations"
|
||||||
export * from "./tasks"
|
export * from "./tasks"
|
||||||
|
export * from "./teams"
|
||||||
export * from "./taxtypes"
|
export * from "./taxtypes"
|
||||||
export * from "./tenants"
|
export * from "./tenants"
|
||||||
export * from "./texttemplates"
|
export * from "./texttemplates"
|
||||||
|
|||||||
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
|
||||||
@@ -50,6 +50,7 @@ export const products = pgTable("products", {
|
|||||||
vendor_allocation: jsonb("vendorAllocation").default([]),
|
vendor_allocation: jsonb("vendorAllocation").default([]),
|
||||||
|
|
||||||
article_number: text("articleNumber"),
|
article_number: text("articleNumber"),
|
||||||
|
supplier_link: text("supplierLink"),
|
||||||
|
|
||||||
barcodes: text("barcodes").array().notNull().default([]),
|
barcodes: text("barcodes").array().notNull().default([]),
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
// foreign keys
|
// foreign keys
|
||||||
bankstatement: integer("bs_id")
|
bankstatement: integer("bs_id").references(() => bankstatements.id),
|
||||||
.notNull()
|
|
||||||
.references(() => bankstatements.id),
|
|
||||||
|
|
||||||
createddocument: integer("cd_id").references(() => createddocuments.id),
|
createddocument: integer("cd_id").references(() => createddocuments.id),
|
||||||
|
|
||||||
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
||||||
() => incominginvoices.id
|
() => incominginvoices.id
|
||||||
),
|
),
|
||||||
|
manualInvoiceSide: text("manual_invoice_side"),
|
||||||
|
|
||||||
tenant: bigint("tenant", { mode: "number" })
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -43,20 +42,43 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
() => accounts.id
|
() => accounts.id
|
||||||
),
|
),
|
||||||
|
|
||||||
|
contraAccount: bigint("contra_account", { mode: "number" }).references(
|
||||||
|
() => accounts.id
|
||||||
|
),
|
||||||
|
|
||||||
created_at: timestamp("created_at", {
|
created_at: timestamp("created_at", {
|
||||||
withTimezone: false,
|
withTimezone: false,
|
||||||
}).defaultNow(),
|
}).defaultNow(),
|
||||||
|
|
||||||
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
|
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
|
||||||
|
|
||||||
|
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
|
||||||
|
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
|
||||||
|
manualBookingDate: text("manual_booking_date"),
|
||||||
|
datevTaxKey: text("datev_tax_key"),
|
||||||
|
|
||||||
|
bookingMode: text("booking_mode").notNull().default("expense"),
|
||||||
|
depreciationMonths: integer("depreciation_months"),
|
||||||
|
depreciationStartDate: text("depreciation_start_date"),
|
||||||
|
depreciationMethod: text("depreciation_method"),
|
||||||
|
depreciationLabel: text("depreciation_label"),
|
||||||
|
depreciationGroup: text("depreciation_group"),
|
||||||
|
residualValue: doublePrecision("residual_value"),
|
||||||
|
|
||||||
customer: bigint("customer", { mode: "number" }).references(
|
customer: bigint("customer", { mode: "number" }).references(
|
||||||
() => customers.id
|
() => customers.id
|
||||||
),
|
),
|
||||||
|
|
||||||
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
|
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
|
||||||
|
|
||||||
|
contraCustomer: bigint("contra_customer", { mode: "number" }).references(
|
||||||
|
() => customers.id
|
||||||
|
),
|
||||||
|
|
||||||
|
contraVendor: bigint("contra_vendor", { mode: "number" }).references(() => vendors.id),
|
||||||
|
|
||||||
updated_at: timestamp("updated_at", { withTimezone: true }),
|
updated_at: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
|
||||||
updated_by: uuid("updated_by").references(() => authUsers.id),
|
updated_by: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
|||||||
40
backend/db/schema/teams.ts
Normal file
40
backend/db/schema/teams.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
bigint,
|
||||||
|
timestamp,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
import { branches } from "./branches"
|
||||||
|
|
||||||
|
export const teams = pgTable("teams", {
|
||||||
|
id: bigint("id", { mode: "number" })
|
||||||
|
.primaryKey()
|
||||||
|
.generatedByDefaultAsIdentity(),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id),
|
||||||
|
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
|
||||||
|
branch: bigint("branch", { mode: "number" })
|
||||||
|
.references(() => branches.id),
|
||||||
|
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Team = typeof teams.$inferSelect
|
||||||
|
export type NewTeam = typeof teams.$inferInsert
|
||||||
@@ -91,7 +91,10 @@ export const tenants = pgTable(
|
|||||||
createDocument: true,
|
createDocument: true,
|
||||||
serialInvoice: true,
|
serialInvoice: true,
|
||||||
incomingInvoices: true,
|
incomingInvoices: true,
|
||||||
|
outgoingsepamandates: true,
|
||||||
costcentres: true,
|
costcentres: true,
|
||||||
|
branches: true,
|
||||||
|
teams: true,
|
||||||
accounts: true,
|
accounts: true,
|
||||||
ownaccounts: true,
|
ownaccounts: true,
|
||||||
banking: true,
|
banking: true,
|
||||||
@@ -127,14 +130,18 @@ export const tenants = pgTable(
|
|||||||
customers: { prefix: "", suffix: "", nextNumber: 10000 },
|
customers: { prefix: "", suffix: "", nextNumber: 10000 },
|
||||||
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
|
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
|
||||||
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
|
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
|
||||||
|
costEstimates: { prefix: "KS-", suffix: "", nextNumber: 1000 },
|
||||||
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
|
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
|
||||||
invoices: { prefix: "RE-", 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 },
|
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
|
||||||
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
|
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
|
||||||
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
|
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
|
||||||
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
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import "dotenv/config"
|
||||||
import { defineConfig } from "drizzle-kit"
|
import { defineConfig } from "drizzle-kit"
|
||||||
import {secrets} from "./src/utils/secrets";
|
|
||||||
|
const fallbackDatabaseUrl = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
|
||||||
|
const databaseUrl = process.env.DATABASE_URL || fallbackDatabaseUrl
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
schema: "./db/schema",
|
schema: "./db/schema",
|
||||||
out: "./db/migrations",
|
out: "./db/migrations",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
|
url: databaseUrl,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
10423
backend/package-lock.json
generated
Normal file
10423
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"migrate": "tsx scripts/migrate.ts",
|
||||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||||
"members:import:csv": "tsx scripts/import-members-csv.ts",
|
"members:import:csv": "tsx scripts/import-members-csv.ts",
|
||||||
|
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
|
||||||
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -52,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",
|
||||||
@@ -61,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",
|
||||||
|
|||||||
589
backend/scripts/import-mitarbeiterliste.ts
Normal file
589
backend/scripts/import-mitarbeiterliste.ts
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
import * as fs from "node:fs"
|
||||||
|
import * as path from "node:path"
|
||||||
|
import { execFileSync } from "node:child_process"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import {
|
||||||
|
authProfileBranches,
|
||||||
|
authProfiles,
|
||||||
|
authProfileTeams,
|
||||||
|
branches,
|
||||||
|
teams,
|
||||||
|
tenants,
|
||||||
|
} from "../db/schema"
|
||||||
|
|
||||||
|
type ImportRow = {
|
||||||
|
rowNumber: number
|
||||||
|
mitarbeiter: string
|
||||||
|
betrieb: string
|
||||||
|
anstellung: string
|
||||||
|
position: string
|
||||||
|
bereich: string
|
||||||
|
stundenMonat: number | null
|
||||||
|
urlaub: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type CliOptions = {
|
||||||
|
workbookPath: string
|
||||||
|
tenantId: number
|
||||||
|
dryRun: boolean
|
||||||
|
defaultBranchId: number | null
|
||||||
|
branchMap: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportAction = "create" | "update"
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`
|
||||||
|
Importiert die Excel-Datei "Mitarbeiterliste.xlsm" in auth_profiles.
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
npm run profiles:import:mitarbeiterliste -- --tenant-id=12 --branch-map-file=./branch-map.json /pfad/zur/Mitarbeiterliste.xlsm
|
||||||
|
|
||||||
|
Optionen:
|
||||||
|
--tenant-id=ID Pflicht. Tenant-ID für den Import.
|
||||||
|
--branch-map='{"Name":1}' Optional. JSON-Mapping Betrieb -> branchId.
|
||||||
|
--branch-map-file=DATEI Optional. JSON-Datei mit Betrieb -> branchId.
|
||||||
|
--default-branch-id=ID Optional. Fallback-Branch-ID für nicht gemappte Betriebe.
|
||||||
|
--dry-run Führt keine Schreiboperationen aus.
|
||||||
|
--help Zeigt diese Hilfe an.
|
||||||
|
|
||||||
|
Beispiel branch-map.json:
|
||||||
|
{
|
||||||
|
"Strandcafé": 10,
|
||||||
|
"1848 Pütt": 11,
|
||||||
|
"Oceans11": 12,
|
||||||
|
"Winnys": 13
|
||||||
|
}
|
||||||
|
`.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliOptions | null {
|
||||||
|
const options: CliOptions = {
|
||||||
|
workbookPath: "/Users/florianfederspiel/Downloads/Mitarbeiterliste.xlsm",
|
||||||
|
tenantId: Number.NaN,
|
||||||
|
dryRun: false,
|
||||||
|
defaultBranchId: null,
|
||||||
|
branchMap: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--dry-run") {
|
||||||
|
options.dryRun = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--tenant-id=")) {
|
||||||
|
options.tenantId = Number(arg.slice("--tenant-id=".length))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--default-branch-id=")) {
|
||||||
|
options.defaultBranchId = Number(arg.slice("--default-branch-id=".length))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--branch-map=")) {
|
||||||
|
options.branchMap = parseBranchMap(arg.slice("--branch-map=".length), "CLI")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--branch-map-file=")) {
|
||||||
|
const branchMapPath = path.resolve(arg.slice("--branch-map-file=".length))
|
||||||
|
options.branchMap = parseBranchMap(fs.readFileSync(branchMapPath, "utf8"), branchMapPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!arg.startsWith("--")) {
|
||||||
|
options.workbookPath = path.resolve(arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unbekanntes Argument: ${arg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(options.tenantId)) {
|
||||||
|
throw new Error("Bitte --tenant-id=... angeben.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.defaultBranchId != null && !Number.isFinite(options.defaultBranchId)) {
|
||||||
|
throw new Error("--default-branch-id muss numerisch sein.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBranchMap(raw: string, sourceLabel: string): Record<string, number> {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Branch-Mapping aus ${sourceLabel} konnte nicht gelesen werden: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`Branch-Mapping aus ${sourceLabel} muss ein JSON-Objekt sein.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEntries = Object.entries(parsed).map(([key, value]) => {
|
||||||
|
const branchId = Number(value)
|
||||||
|
if (!Number.isFinite(branchId)) {
|
||||||
|
throw new Error(`Ungültige Branch-ID für "${key}" in ${sourceLabel}.`)
|
||||||
|
}
|
||||||
|
return [normalizeKey(key), branchId] as const
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.fromEntries(normalizedEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(value: string) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.toLocaleLowerCase("de-DE")
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlText(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, "\"")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkbookXml(workbookPath: string, innerPath: string) {
|
||||||
|
return execFileSync("unzip", ["-p", workbookPath, innerPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSharedStrings(workbookPath: string) {
|
||||||
|
const xml = getWorkbookXml(workbookPath, "xl/sharedStrings.xml")
|
||||||
|
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) => {
|
||||||
|
const parts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) => decodeXmlText(part[1]))
|
||||||
|
return parts.join("")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSheetRows(workbookPath: string, sharedStrings: string[]) {
|
||||||
|
const sheetXml = getWorkbookXml(workbookPath, "xl/worksheets/sheet1.xml")
|
||||||
|
const rows: Record<string, string>[] = []
|
||||||
|
|
||||||
|
for (const rowMatch of sheetXml.matchAll(/<row\b[^>]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||||
|
const cellMap: Record<string, string> = {}
|
||||||
|
const rowXml = rowMatch[2]
|
||||||
|
|
||||||
|
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||||
|
const attrs = cellMatch[1]
|
||||||
|
const cellXml = cellMatch[2]
|
||||||
|
const refMatch = attrs.match(/r="([A-Z]+)\d+"/)
|
||||||
|
if (!refMatch) continue
|
||||||
|
|
||||||
|
const column = refMatch[1]
|
||||||
|
const typeMatch = attrs.match(/t="([^"]+)"/)
|
||||||
|
const type = typeMatch?.[1] || ""
|
||||||
|
const valueMatch = cellXml.match(/<v>([\s\S]*?)<\/v>/)
|
||||||
|
const inlineTextMatch = cellXml.match(/<t\b[^>]*>([\s\S]*?)<\/t>/)
|
||||||
|
|
||||||
|
let value = ""
|
||||||
|
if (type === "s" && valueMatch) {
|
||||||
|
value = sharedStrings[Number(valueMatch[1])] || ""
|
||||||
|
} else if (inlineTextMatch) {
|
||||||
|
value = decodeXmlText(inlineTextMatch[1])
|
||||||
|
} else if (valueMatch) {
|
||||||
|
value = decodeXmlText(valueMatch[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
cellMap[column] = value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(cellMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string) {
|
||||||
|
const normalized = String(value || "").trim().replace(",", ".")
|
||||||
|
if (!normalized) return null
|
||||||
|
|
||||||
|
const parsed = Number(normalized)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkbook(workbookPath: string): ImportRow[] {
|
||||||
|
const sharedStrings = readSharedStrings(workbookPath)
|
||||||
|
const rows = readSheetRows(workbookPath, sharedStrings)
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
throw new Error("Die Arbeitsmappe enthält keine Zeilen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = rows[0]
|
||||||
|
if (header.A !== "Mitarbeiter" || header.B !== "Betrieb") {
|
||||||
|
throw new Error("Unerwartetes Format der Excel-Datei. Erwartet wurden die Spalten 'Mitarbeiter' und 'Betrieb'.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.slice(1)
|
||||||
|
.map((row, index) => ({
|
||||||
|
rowNumber: index + 2,
|
||||||
|
mitarbeiter: row.A || "",
|
||||||
|
betrieb: row.B || "",
|
||||||
|
anstellung: row.C || "",
|
||||||
|
position: row.D || "",
|
||||||
|
bereich: row.E || "",
|
||||||
|
stundenMonat: parseNumber(row.F || ""),
|
||||||
|
urlaub: parseNumber(row.G || ""),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.mitarbeiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFullName(fullName: string) {
|
||||||
|
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
return {
|
||||||
|
firstName: fullName.trim(),
|
||||||
|
lastName: "Unbekannt",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstName: parts.slice(0, -1).join(" "),
|
||||||
|
lastName: parts[parts.length - 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWeeklyHours(monthlyHours: number | null) {
|
||||||
|
if (monthlyHours == null) return null
|
||||||
|
return Math.round(((monthlyHours * 12) / 52) * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown) {
|
||||||
|
if (value == null) return "-"
|
||||||
|
if (typeof value === "object") return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEmploymentCategory(value: string) {
|
||||||
|
const normalized = normalizeKey(value)
|
||||||
|
if (normalized === "aushilfe") return "Aushilfen"
|
||||||
|
if (normalized === "teilzeit" || normalized === "vollzeit") return "Festangestellte"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTeamName(bereich: string, anstellung: string) {
|
||||||
|
const employmentCategory = mapEmploymentCategory(anstellung)
|
||||||
|
const normalizedBereich = String(bereich || "").trim()
|
||||||
|
if (!normalizedBereich || !employmentCategory) return null
|
||||||
|
return `${normalizedBereich} ${employmentCategory}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFieldChanges(existing: any, nextPayload: Record<string, unknown>) {
|
||||||
|
if (!existing) return []
|
||||||
|
|
||||||
|
const changes: string[] = []
|
||||||
|
for (const [field, nextValue] of Object.entries(nextPayload)) {
|
||||||
|
const currentValue = existing[field]
|
||||||
|
const currentFormatted = formatValue(currentValue)
|
||||||
|
const nextFormatted = formatValue(nextValue)
|
||||||
|
|
||||||
|
if (currentFormatted !== nextFormatted) {
|
||||||
|
changes.push(`${field}: ${currentFormatted} -> ${nextFormatted}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateTenantAndBranches(
|
||||||
|
db: any,
|
||||||
|
tenantId: number,
|
||||||
|
branchIds: number[]
|
||||||
|
) {
|
||||||
|
const [tenant] = await db
|
||||||
|
.select({ id: tenants.id, name: tenants.name })
|
||||||
|
.from(tenants)
|
||||||
|
.where(eq(tenants.id, tenantId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error(`Tenant ${tenantId} wurde nicht gefunden.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchRows = await db
|
||||||
|
.select({ id: branches.id, name: branches.name })
|
||||||
|
.from(branches)
|
||||||
|
.where(eq(branches.tenant, tenantId))
|
||||||
|
|
||||||
|
const validBranchIds = new Set(branchRows.map((branch: any) => Number(branch.id)))
|
||||||
|
for (const branchId of branchIds) {
|
||||||
|
if (!validBranchIds.has(branchId)) {
|
||||||
|
throw new Error(`Branch-ID ${branchId} gehört nicht zum Tenant ${tenantId}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant,
|
||||||
|
branchRows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTenantTeams(db: any, tenantId: number) {
|
||||||
|
const teamRows = await db
|
||||||
|
.select({
|
||||||
|
id: teams.id,
|
||||||
|
name: teams.name,
|
||||||
|
branch: teams.branch,
|
||||||
|
archived: teams.archived,
|
||||||
|
})
|
||||||
|
.from(teams)
|
||||||
|
.where(eq(teams.tenant, tenantId))
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
teamRows
|
||||||
|
.filter((team: any) => !team.archived)
|
||||||
|
.map((team: any) => [`${team.branch}::${normalizeKey(team.name)}`, team])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2))
|
||||||
|
if (!options) {
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(options.workbookPath)) {
|
||||||
|
throw new Error(`Excel-Datei nicht gefunden: ${options.workbookPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = parseWorkbook(options.workbookPath)
|
||||||
|
if (!rows.length) {
|
||||||
|
throw new Error("Keine importierbaren Mitarbeiter gefunden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedBranchIds = [
|
||||||
|
...new Set(
|
||||||
|
Object.values(options.branchMap)
|
||||||
|
.concat(options.defaultBranchId != null ? [options.defaultBranchId] : [])
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const { db, pool } = await import("../db")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tenant } = await validateTenantAndBranches(db, options.tenantId, mappedBranchIds)
|
||||||
|
const teamByBranchAndName = await loadTenantTeams(db, options.tenantId)
|
||||||
|
|
||||||
|
const existingProfiles = await db
|
||||||
|
.select()
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(eq(authProfiles.tenant_id, options.tenantId))
|
||||||
|
|
||||||
|
const existingByName = new Map<string, any>()
|
||||||
|
for (const profile of existingProfiles) {
|
||||||
|
const key = normalizeKey(`${profile.first_name} ${profile.last_name}`)
|
||||||
|
if (existingByName.has(key)) {
|
||||||
|
throw new Error(`Mehrdeutiger bestehender Mitarbeiter: ${profile.first_name} ${profile.last_name}`)
|
||||||
|
}
|
||||||
|
existingByName.set(key, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateImportNames = new Set<string>()
|
||||||
|
const seenImportNames = new Set<string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = normalizeKey(row.mitarbeiter)
|
||||||
|
if (seenImportNames.has(key)) duplicateImportNames.add(row.mitarbeiter)
|
||||||
|
seenImportNames.add(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateImportNames.size) {
|
||||||
|
throw new Error(`Die Excel-Datei enthält doppelte Mitarbeiternamen: ${[...duplicateImportNames].join(", ")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingBranchMappings = new Set<string>()
|
||||||
|
const missingTeams = new Set<string>()
|
||||||
|
const preparedRows = rows.map((row) => {
|
||||||
|
const branchKey = normalizeKey(row.betrieb)
|
||||||
|
const branchId = options.branchMap[branchKey] ?? options.defaultBranchId ?? null
|
||||||
|
if (!branchId) {
|
||||||
|
missingBranchMappings.add(row.betrieb || `(Zeile ${row.rowNumber})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamName = buildTeamName(row.bereich, row.anstellung)
|
||||||
|
const team = branchId && teamName
|
||||||
|
? (teamByBranchAndName.get(`${branchId}::${normalizeKey(teamName)}`) as any) || null
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (branchId && teamName && !team) {
|
||||||
|
missingTeams.add(`${row.betrieb} | ${teamName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
branchId,
|
||||||
|
teamId: team?.id ?? null,
|
||||||
|
teamName,
|
||||||
|
weeklyHours: toWeeklyHours(row.stundenMonat),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (missingBranchMappings.size) {
|
||||||
|
throw new Error(
|
||||||
|
`Für folgende Betriebe fehlt eine Branch-ID: ${[...missingBranchMappings].join(", ")}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingTeams.size) {
|
||||||
|
throw new Error(
|
||||||
|
`Für folgende Niederlassung-/Bereich-Kombinationen fehlen Teams: ${[...missingTeams].join(", ")}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let createdProfiles = 0
|
||||||
|
let updatedProfiles = 0
|
||||||
|
const actionLogs: string[] = []
|
||||||
|
|
||||||
|
for (const row of preparedRows) {
|
||||||
|
const nameParts = splitFullName(row.mitarbeiter)
|
||||||
|
const nameKey = normalizeKey(row.mitarbeiter)
|
||||||
|
const existing = existingByName.get(nameKey)
|
||||||
|
|
||||||
|
const tempConfig = {
|
||||||
|
...((existing?.temp_config && typeof existing.temp_config === "object") ? existing.temp_config : {}),
|
||||||
|
mitarbeiterImport: {
|
||||||
|
betrieb: row.betrieb,
|
||||||
|
bereich: row.bereich,
|
||||||
|
stundenMonat: row.stundenMonat,
|
||||||
|
urlaub: row.urlaub,
|
||||||
|
quelle: path.basename(options.workbookPath),
|
||||||
|
importiertAm: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
tenant_id: options.tenantId,
|
||||||
|
branch_id: row.branchId,
|
||||||
|
first_name: nameParts.firstName,
|
||||||
|
last_name: nameParts.lastName,
|
||||||
|
contract_type: row.anstellung || null,
|
||||||
|
position: row.position || null,
|
||||||
|
qualification: row.bereich || null,
|
||||||
|
weekly_working_hours: row.weeklyHours ?? existing?.weekly_working_hours ?? 0,
|
||||||
|
annual_paid_leave_days: row.urlaub != null ? Math.round(row.urlaub) : existing?.annual_paid_leave_days ?? null,
|
||||||
|
temp_config: tempConfig,
|
||||||
|
active: existing?.active ?? true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const action: ImportAction = existing ? "update" : "create"
|
||||||
|
const fieldChanges = existing ? collectFieldChanges(existing, payload) : []
|
||||||
|
const actionPrefix = action === "create" ? "ERSTELLEN" : "AKTUALISIEREN"
|
||||||
|
const branchLabel = `${row.betrieb} -> ${row.branchId}`
|
||||||
|
const teamLabel = row.teamName ? `${row.teamName} -> ${row.teamId}` : "-"
|
||||||
|
|
||||||
|
if (action === "create") {
|
||||||
|
actionLogs.push(
|
||||||
|
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Vertrag ${row.anstellung || "-"} | Position ${row.position || "-"}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
actionLogs.push(
|
||||||
|
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Änderungen: ${fieldChanges.length ? fieldChanges.join("; ") : "keine"}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
if (!options.dryRun) {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(authProfiles)
|
||||||
|
.values(payload)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
throw new Error(`Profil für "${row.mitarbeiter}" konnte nicht erstellt werden.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(authProfileBranches).values({
|
||||||
|
profile_id: created.id,
|
||||||
|
branch_id: row.branchId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (row.teamId) {
|
||||||
|
await db.insert(authProfileTeams).values({
|
||||||
|
profile_id: created.id,
|
||||||
|
team_id: row.teamId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
existingByName.set(nameKey, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
createdProfiles += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.dryRun) {
|
||||||
|
await db
|
||||||
|
.update(authProfiles)
|
||||||
|
.set(payload)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(authProfiles.id, existing.id),
|
||||||
|
eq(authProfiles.tenant_id, options.tenantId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(authProfileBranches)
|
||||||
|
.where(eq(authProfileBranches.profile_id, existing.id))
|
||||||
|
|
||||||
|
await db.insert(authProfileBranches).values({
|
||||||
|
profile_id: existing.id,
|
||||||
|
branch_id: row.branchId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(authProfileTeams)
|
||||||
|
.where(eq(authProfileTeams.profile_id, existing.id))
|
||||||
|
|
||||||
|
if (row.teamId) {
|
||||||
|
await db.insert(authProfileTeams).values({
|
||||||
|
profile_id: existing.id,
|
||||||
|
team_id: row.teamId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedProfiles += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("")
|
||||||
|
console.log(`[IMPORT MITARBEITER] Tenant: ${tenant.id} (${tenant.name})`)
|
||||||
|
console.log(`[IMPORT MITARBEITER] Datei: ${options.workbookPath}`)
|
||||||
|
console.log(`[IMPORT MITARBEITER] Dry-Run: ${options.dryRun ? "JA" : "NEIN"}`)
|
||||||
|
console.log(`[IMPORT MITARBEITER] Zeilen gelesen: ${rows.length}`)
|
||||||
|
console.log(`[IMPORT MITARBEITER] Profile erstellt: ${createdProfiles}`)
|
||||||
|
console.log(`[IMPORT MITARBEITER] Profile aktualisiert: ${updatedProfiles}`)
|
||||||
|
if (actionLogs.length) {
|
||||||
|
console.log("[IMPORT MITARBEITER] Details:")
|
||||||
|
for (const logLine of actionLogs) {
|
||||||
|
console.log(` ${logLine}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("")
|
||||||
|
} finally {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[IMPORT MITARBEITER] Fehler:", error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
47
backend/scripts/migrate.ts
Normal file
47
backend/scripts/migrate.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import "dotenv/config"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
|
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||||
|
import { Pool } from "pg"
|
||||||
|
|
||||||
|
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
let connectionString = process.env.DATABASE_URL
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
await loadSecrets()
|
||||||
|
connectionString = secrets.DATABASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = drizzle(pool)
|
||||||
|
|
||||||
|
await migrate(db, {
|
||||||
|
migrationsFolder: path.resolve(__dirname, "../db/migrations"),
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("✅ Drizzle-Migrationen erfolgreich angewendet")
|
||||||
|
} finally {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error("❌ Migration fehlgeschlagen")
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
7
backend/scripts/mitarbeiterliste-tenant-41-branches.json
Normal file
7
backend/scripts/mitarbeiterliste-tenant-41-branches.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"1848 Pütt": 1,
|
||||||
|
"Strandcafé": 3,
|
||||||
|
"Oceans11": 4,
|
||||||
|
"Oceans 11": 4,
|
||||||
|
"Winnys": 5
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
|||||||
import userRoutes from "./routes/auth/user";
|
import userRoutes from "./routes/auth/user";
|
||||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||||
import wikiRoutes from "./routes/wiki";
|
import wikiRoutes from "./routes/wiki";
|
||||||
|
import portalContractRoutes from "./routes/portal/contracts";
|
||||||
|
import mcpRoutes from "./routes/mcp";
|
||||||
|
import communicationRoutes from "./routes/communication";
|
||||||
|
|
||||||
//Public Links
|
//Public Links
|
||||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||||
@@ -53,6 +56,7 @@ 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";
|
||||||
|
|
||||||
|
|
||||||
//Services
|
//Services
|
||||||
@@ -77,6 +81,7 @@ 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);
|
||||||
|
|
||||||
app.addHook('preHandler', (req, reply, done) => {
|
app.addHook('preHandler', (req, reply, done) => {
|
||||||
console.log(req.method)
|
console.log(req.method)
|
||||||
@@ -146,6 +151,9 @@ async function main() {
|
|||||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||||
await subApp.register(resourceRoutes);
|
await subApp.register(resourceRoutes);
|
||||||
await subApp.register(wikiRoutes);
|
await subApp.register(wikiRoutes);
|
||||||
|
await subApp.register(portalContractRoutes);
|
||||||
|
await subApp.register(mcpRoutes);
|
||||||
|
await subApp.register(communicationRoutes);
|
||||||
|
|
||||||
},{prefix: "/api"})
|
},{prefix: "/api"})
|
||||||
|
|
||||||
|
|||||||
88
backend/src/mcp/authz.ts
Normal file
88
backend/src/mcp/authz.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest } from "fastify"
|
||||||
|
import { and, eq, or, isNull, inArray } from "drizzle-orm"
|
||||||
|
import {
|
||||||
|
authRoles,
|
||||||
|
authRolePermissions,
|
||||||
|
authUserRoles,
|
||||||
|
} from "../../db/schema"
|
||||||
|
import { McpContext, McpTool } from "./types"
|
||||||
|
|
||||||
|
export async function loadTenantPermissions(
|
||||||
|
server: FastifyInstance,
|
||||||
|
userId: string,
|
||||||
|
tenantId: number
|
||||||
|
) {
|
||||||
|
const roleRows = await server.db
|
||||||
|
.select({
|
||||||
|
roleId: authUserRoles.role_id,
|
||||||
|
})
|
||||||
|
.from(authUserRoles)
|
||||||
|
.innerJoin(
|
||||||
|
authRoles,
|
||||||
|
and(
|
||||||
|
eq(authRoles.id, authUserRoles.role_id),
|
||||||
|
or(isNull(authRoles.tenant_id), eq(authRoles.tenant_id, tenantId))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(authUserRoles.user_id, userId),
|
||||||
|
eq(authUserRoles.tenant_id, tenantId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const roleIds = Array.from(new Set(roleRows.map((row) => row.roleId)))
|
||||||
|
|
||||||
|
if (roleIds.length === 0) return []
|
||||||
|
|
||||||
|
const permissionRows = await server.db
|
||||||
|
.select({
|
||||||
|
permission: authRolePermissions.permission,
|
||||||
|
})
|
||||||
|
.from(authRolePermissions)
|
||||||
|
.where(inArray(authRolePermissions.role_id, roleIds))
|
||||||
|
|
||||||
|
return Array.from(new Set(permissionRows.map((row) => row.permission)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMcpContext(
|
||||||
|
server: FastifyInstance,
|
||||||
|
request: FastifyRequest
|
||||||
|
): Promise<McpContext> {
|
||||||
|
const user = request.user
|
||||||
|
|
||||||
|
if (!user?.user_id) {
|
||||||
|
throw Object.assign(new Error("Authentication required"), { statusCode: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.tenant_id) {
|
||||||
|
throw Object.assign(new Error("MCP benötigt einen aktiven Mandanten"), { statusCode: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await loadTenantPermissions(server, user.user_id, user.tenant_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
request,
|
||||||
|
tenantId: user.tenant_id,
|
||||||
|
userId: user.user_id,
|
||||||
|
isAdmin: Boolean(user.is_admin),
|
||||||
|
permissions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertToolPermission(context: McpContext, tool: McpTool) {
|
||||||
|
if (context.isAdmin) return
|
||||||
|
|
||||||
|
const allowed = tool.requiredPermissions.every((permission) =>
|
||||||
|
context.permissions.includes(permission)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`Fehlende Berechtigung für ${tool.name}: ${tool.requiredPermissions.join(", ")}`),
|
||||||
|
{ statusCode: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
11
backend/src/mcp/registry.ts
Normal file
11
backend/src/mcp/registry.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { accountingTools } from "./tools/accounting"
|
||||||
|
import { masterdataTools } from "./tools/masterdata"
|
||||||
|
import { organisationTools } from "./tools/organisation"
|
||||||
|
|
||||||
|
export const mcpTools = [
|
||||||
|
...accountingTools,
|
||||||
|
...masterdataTools,
|
||||||
|
...organisationTools,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||||
36
backend/src/mcp/result.ts
Normal file
36
backend/src/mcp/result.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { McpToolResult } from "./types"
|
||||||
|
|
||||||
|
export function asToolResult(payload: unknown): McpToolResult {
|
||||||
|
const structuredContent =
|
||||||
|
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||||
|
? payload as Record<string, unknown>
|
||||||
|
: { result: payload }
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(payload, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asToolError(error: unknown): McpToolResult {
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
structuredContent: {
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
1174
backend/src/mcp/tools/accounting.ts
Normal file
1174
backend/src/mcp/tools/accounting.ts
Normal file
File diff suppressed because it is too large
Load Diff
571
backend/src/mcp/tools/masterdata.ts
Normal file
571
backend/src/mcp/tools/masterdata.ts
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
|
import {
|
||||||
|
branches,
|
||||||
|
contacts,
|
||||||
|
costcentres,
|
||||||
|
customers,
|
||||||
|
inventoryitems,
|
||||||
|
products,
|
||||||
|
services,
|
||||||
|
teams,
|
||||||
|
units,
|
||||||
|
vehicles,
|
||||||
|
vendors,
|
||||||
|
} from "../../../db/schema"
|
||||||
|
import { McpTool } from "../types"
|
||||||
|
|
||||||
|
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||||
|
const raw = Number(args.limit ?? fallback)
|
||||||
|
if (!Number.isFinite(raw)) return fallback
|
||||||
|
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = args[key]
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = Number(args[key])
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = stringArg(args, key)
|
||||||
|
return value && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||||
|
? value
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const masterdataTools: McpTool[] = [
|
||||||
|
{
|
||||||
|
name: "masterdata.customers.get",
|
||||||
|
title: "Kunde laden",
|
||||||
|
description: "Lädt einen Kunden des aktiven Mandanten anhand seiner ID.",
|
||||||
|
requiredPermissions: ["masterdata.customers.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "number" } },
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) throw new Error("Kunde nicht gefunden")
|
||||||
|
return { customer: rows[0] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.vendors.search",
|
||||||
|
title: "Lieferanten suchen",
|
||||||
|
description: "Sucht Lieferanten des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["masterdata.vendors.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(vendors.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(vendors.name, `%${query}%`),
|
||||||
|
ilike(vendors.vendorNumber, `%${query}%`),
|
||||||
|
ilike(vendors.notes, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(vendors)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(vendors.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.vendors.get",
|
||||||
|
title: "Lieferant laden",
|
||||||
|
description: "Lädt einen Lieferanten des aktiven Mandanten anhand seiner ID.",
|
||||||
|
requiredPermissions: ["masterdata.vendors.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "number" } },
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(vendors)
|
||||||
|
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
|
||||||
|
return { vendor: rows[0] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.contacts.search",
|
||||||
|
title: "Kontakte suchen",
|
||||||
|
description: "Sucht Kontakte des aktiven Mandanten, optional zu Kunde oder Lieferant.",
|
||||||
|
requiredPermissions: ["masterdata.contacts.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
||||||
|
customer: { type: "number" },
|
||||||
|
vendor: { type: "number" },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(contacts.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
const customer = numberArg(args, "customer")
|
||||||
|
const vendor = numberArg(args, "vendor")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(contacts.fullName, `%${query}%`),
|
||||||
|
ilike(contacts.firstName, `%${query}%`),
|
||||||
|
ilike(contacts.lastName, `%${query}%`),
|
||||||
|
ilike(contacts.email, `%${query}%`),
|
||||||
|
ilike(contacts.phoneMobile, `%${query}%`),
|
||||||
|
ilike(contacts.phoneHome, `%${query}%`),
|
||||||
|
ilike(contacts.role, `%${query}%`),
|
||||||
|
ilike(contacts.notes, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (customer) conditions.push(eq(contacts.customer, customer))
|
||||||
|
if (vendor) conditions.push(eq(contacts.vendor, vendor))
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(contacts.fullName)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.products.search",
|
||||||
|
title: "Artikel suchen",
|
||||||
|
description: "Sucht Artikel und Materialstammdaten des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["masterdata.products.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(products.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(products.name, `%${query}%`),
|
||||||
|
ilike(products.article_number, `%${query}%`),
|
||||||
|
ilike(products.manufacturer, `%${query}%`),
|
||||||
|
ilike(products.manufacturer_number, `%${query}%`),
|
||||||
|
ilike(products.ean, `%${query}%`),
|
||||||
|
ilike(products.barcode, `%${query}%`),
|
||||||
|
ilike(products.description, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(products)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(products.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.products.get",
|
||||||
|
title: "Artikel laden",
|
||||||
|
description: "Lädt einen Artikel des aktiven Mandanten anhand seiner ID.",
|
||||||
|
requiredPermissions: ["masterdata.products.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "number" } },
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(products)
|
||||||
|
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) throw new Error("Artikel nicht gefunden")
|
||||||
|
return { product: rows[0] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.services.search",
|
||||||
|
title: "Leistungen suchen",
|
||||||
|
description: "Sucht Leistungsstammdaten des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["masterdata.services.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(services.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(services.name, `%${query}%`),
|
||||||
|
ilike(services.description, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(services)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(services.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.services.get",
|
||||||
|
title: "Leistung laden",
|
||||||
|
description: "Lädt eine Leistung des aktiven Mandanten anhand ihrer ID.",
|
||||||
|
requiredPermissions: ["masterdata.services.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "number" } },
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(services)
|
||||||
|
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) throw new Error("Leistung nicht gefunden")
|
||||||
|
return { service: rows[0] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.cost_centres.list",
|
||||||
|
title: "Kostenstellen auflisten",
|
||||||
|
description: "Listet Kostenstellen des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["masterdata.cost_centres.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||||
|
branch: { type: "number" },
|
||||||
|
project: { type: "number" },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(costcentres.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
const branch = numberArg(args, "branch")
|
||||||
|
const project = numberArg(args, "project")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(costcentres.number, `%${query}%`),
|
||||||
|
ilike(costcentres.name, `%${query}%`),
|
||||||
|
ilike(costcentres.description, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (branch) conditions.push(eq(costcentres.branch, branch))
|
||||||
|
if (project) conditions.push(eq(costcentres.project, project))
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(costcentres)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(costcentres.number)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.cost_centres.get",
|
||||||
|
title: "Kostenstelle laden",
|
||||||
|
description: "Lädt eine Kostenstelle des aktiven Mandanten anhand ihrer UUID.",
|
||||||
|
requiredPermissions: ["masterdata.cost_centres.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "string" } },
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = uuidArg(args, "id")
|
||||||
|
if (!id) throw new Error("gültige id ist erforderlich")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(costcentres)
|
||||||
|
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
|
||||||
|
return { costCentre: rows[0] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.branches.list",
|
||||||
|
title: "Niederlassungen auflisten",
|
||||||
|
description: "Listet Niederlassungen des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["masterdata.branches.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(branches.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(branches.number, `%${query}%`),
|
||||||
|
ilike(branches.name, `%${query}%`),
|
||||||
|
ilike(branches.description, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(branches)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(branches.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.teams.list",
|
||||||
|
title: "Teams auflisten",
|
||||||
|
description: "Listet Teams des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["masterdata.teams.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
||||||
|
branch: { type: "number" },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(teams.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
const branch = numberArg(args, "branch")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(teams.name, `%${query}%`),
|
||||||
|
ilike(teams.description, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (branch) conditions.push(eq(teams.branch, branch))
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(teams)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(teams.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.vehicles.list",
|
||||||
|
title: "Fahrzeuge auflisten",
|
||||||
|
description: "Listet Fahrzeuge des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["masterdata.vehicles.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(vehicles.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(vehicles.name, `%${query}%`),
|
||||||
|
ilike(vehicles.license_plate, `%${query}%`),
|
||||||
|
ilike(vehicles.vin, `%${query}%`),
|
||||||
|
ilike(vehicles.color, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(vehicles)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(vehicles.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.inventory_items.search",
|
||||||
|
title: "Inventar suchen",
|
||||||
|
description: "Sucht Inventar- und Geräte-Stammdaten des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["masterdata.inventory_items.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
||||||
|
vendor: { type: "number" },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(inventoryitems.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
const vendor = numberArg(args, "vendor")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(inventoryitems.name, `%${query}%`),
|
||||||
|
ilike(inventoryitems.articleNumber, `%${query}%`),
|
||||||
|
ilike(inventoryitems.serialNumber, `%${query}%`),
|
||||||
|
ilike(inventoryitems.manufacturer, `%${query}%`),
|
||||||
|
ilike(inventoryitems.manufacturerNumber, `%${query}%`),
|
||||||
|
ilike(inventoryitems.description, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(inventoryitems)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(inventoryitems.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.inventory_items.get",
|
||||||
|
title: "Inventar laden",
|
||||||
|
description: "Lädt einen Inventar- oder Geräte-Stammdatensatz anhand seiner ID.",
|
||||||
|
requiredPermissions: ["masterdata.inventory_items.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "number" } },
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(inventoryitems)
|
||||||
|
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) throw new Error("Inventar nicht gefunden")
|
||||||
|
return { inventoryItem: rows[0] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masterdata.units.list",
|
||||||
|
title: "Einheiten auflisten",
|
||||||
|
description: "Listet globale Mengeneinheiten.",
|
||||||
|
requiredPermissions: ["masterdata.units.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(units)
|
||||||
|
.where(query
|
||||||
|
? or(
|
||||||
|
ilike(units.name, `%${query}%`),
|
||||||
|
ilike(units.single, `%${query}%`),
|
||||||
|
ilike(units.multiple, `%${query}%`),
|
||||||
|
ilike(units.short, `%${query}%`)
|
||||||
|
)
|
||||||
|
: undefined)
|
||||||
|
.orderBy(units.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
407
backend/src/mcp/tools/organisation.ts
Normal file
407
backend/src/mcp/tools/organisation.ts
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
|
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
||||||
|
import { McpTool } from "../types"
|
||||||
|
|
||||||
|
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||||
|
const raw = Number(args.limit ?? fallback)
|
||||||
|
if (!Number.isFinite(raw)) return fallback
|
||||||
|
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = args[key]
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = Number(args[key])
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const organisationTools: McpTool[] = [
|
||||||
|
{
|
||||||
|
name: "organisation.customers.search",
|
||||||
|
title: "Kunden suchen",
|
||||||
|
description: "Sucht aktive Kunden des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["organisation.customers.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(customers.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(customers.name, `%${query}%`),
|
||||||
|
ilike(customers.customerNumber, `%${query}%`),
|
||||||
|
ilike(customers.firstname, `%${query}%`),
|
||||||
|
ilike(customers.lastname, `%${query}%`),
|
||||||
|
ilike(customers.notes, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select({
|
||||||
|
id: customers.id,
|
||||||
|
customerNumber: customers.customerNumber,
|
||||||
|
name: customers.name,
|
||||||
|
firstname: customers.firstname,
|
||||||
|
lastname: customers.lastname,
|
||||||
|
type: customers.type,
|
||||||
|
isCompany: customers.isCompany,
|
||||||
|
active: customers.active,
|
||||||
|
archived: customers.archived,
|
||||||
|
})
|
||||||
|
.from(customers)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(customers.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.projects.list",
|
||||||
|
title: "Projekte auflisten",
|
||||||
|
description: "Listet Projekte des aktiven Mandanten mit optionalen Filtern.",
|
||||||
|
requiredPermissions: ["organisation.projects.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
||||||
|
customer: { type: "number" },
|
||||||
|
activePhase: { type: "string" },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(projects.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
const customer = numberArg(args, "customer")
|
||||||
|
const activePhase = stringArg(args, "activePhase")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(projects.name, `%${query}%`),
|
||||||
|
ilike(projects.projectNumber, `%${query}%`),
|
||||||
|
ilike(projects.customerRef, `%${query}%`),
|
||||||
|
ilike(projects.notes, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (customer) conditions.push(eq(projects.customer, customer))
|
||||||
|
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(projects.createdAt))
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.projects.get",
|
||||||
|
title: "Projekt laden",
|
||||||
|
description: "Lädt ein Projekt des aktiven Mandanten anhand seiner ID.",
|
||||||
|
requiredPermissions: ["organisation.projects.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
||||||
|
return { project: rows[0] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.plants.list",
|
||||||
|
title: "Anlagen auflisten",
|
||||||
|
description: "Listet Anlagen des aktiven Mandanten mit optionalem Kundenfilter.",
|
||||||
|
requiredPermissions: ["organisation.plants.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name." },
|
||||||
|
customer: { type: "number" },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(plants.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
const customer = numberArg(args, "customer")
|
||||||
|
|
||||||
|
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
||||||
|
if (customer) conditions.push(eq(plants.customer, customer))
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(plants)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(plants.name)
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.events.list",
|
||||||
|
title: "Termine auflisten",
|
||||||
|
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
|
||||||
|
requiredPermissions: ["organisation.events.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
|
||||||
|
project: { type: "number" },
|
||||||
|
customer: { type: "number" },
|
||||||
|
eventtype: { type: "string" },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(events.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
const project = numberArg(args, "project")
|
||||||
|
const customer = numberArg(args, "customer")
|
||||||
|
const eventtype = stringArg(args, "eventtype")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(events.name, `%${query}%`),
|
||||||
|
ilike(events.notes, `%${query}%`),
|
||||||
|
ilike(events.link, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (project) conditions.push(eq(events.project, project))
|
||||||
|
if (customer) conditions.push(eq(events.customer, customer))
|
||||||
|
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(events.startDate))
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.tasks.list",
|
||||||
|
title: "Aufgaben auflisten",
|
||||||
|
description: "Listet Aufgaben des aktiven Mandanten mit optionalen Filtern.",
|
||||||
|
requiredPermissions: ["organisation.tasks.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
||||||
|
project: { type: "number" },
|
||||||
|
customer: { type: "number" },
|
||||||
|
includeArchived: { type: "boolean", default: false },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(tasks.tenant, context.tenantId)]
|
||||||
|
const query = stringArg(args, "query")
|
||||||
|
const project = numberArg(args, "project")
|
||||||
|
const customer = numberArg(args, "customer")
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(tasks.name, `%${query}%`),
|
||||||
|
ilike(tasks.description, `%${query}%`),
|
||||||
|
ilike(tasks.categorie, `%${query}%`)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (project) conditions.push(eq(tasks.project, project))
|
||||||
|
if (customer) conditions.push(eq(tasks.customer, customer))
|
||||||
|
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(tasks.createdAt))
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.tasks.get",
|
||||||
|
title: "Aufgabe laden",
|
||||||
|
description: "Lädt eine Aufgabe des aktiven Mandanten anhand ihrer ID.",
|
||||||
|
requiredPermissions: ["organisation.tasks.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
||||||
|
return { task: rows[0] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.tasks.create",
|
||||||
|
title: "Aufgabe erstellen",
|
||||||
|
description: "Erstellt eine neue Aufgabe im aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["organisation.tasks.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["name"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
categorie: { type: "string" },
|
||||||
|
project: { type: "number" },
|
||||||
|
plant: { type: "number" },
|
||||||
|
customer: { type: "number" },
|
||||||
|
userId: { type: "string" },
|
||||||
|
profiles: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const name = stringArg(args, "name")
|
||||||
|
if (!name) throw new Error("name ist erforderlich")
|
||||||
|
|
||||||
|
const [created] = await context.server.db
|
||||||
|
.insert(tasks)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
description: stringArg(args, "description"),
|
||||||
|
categorie: stringArg(args, "categorie"),
|
||||||
|
tenant: context.tenantId,
|
||||||
|
userId: stringArg(args, "userId") || context.userId,
|
||||||
|
project: numberArg(args, "project"),
|
||||||
|
plant: numberArg(args, "plant"),
|
||||||
|
customer: numberArg(args, "customer"),
|
||||||
|
profiles: Array.isArray(args.profiles) ? args.profiles : [],
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: context.userId,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return { task: created }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.tasks.update",
|
||||||
|
title: "Aufgabe aktualisieren",
|
||||||
|
description: "Aktualisiert Felder einer Aufgabe im aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["organisation.tasks.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
name: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
categorie: { type: "string" },
|
||||||
|
project: { type: "number" },
|
||||||
|
plant: { type: "number" },
|
||||||
|
customer: { type: "number" },
|
||||||
|
userId: { type: "string" },
|
||||||
|
profiles: { type: "array", items: { type: "string" } },
|
||||||
|
archived: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const update: Record<string, unknown> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: context.userId,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ["name", "description", "categorie", "userId"] as const) {
|
||||||
|
if (args[key] !== undefined) update[key] = stringArg(args, key)
|
||||||
|
}
|
||||||
|
for (const key of ["project", "plant", "customer"] as const) {
|
||||||
|
if (args[key] !== undefined) update[key] = numberArg(args, key)
|
||||||
|
}
|
||||||
|
if (Array.isArray(args.profiles)) update.profiles = args.profiles
|
||||||
|
if (typeof args.archived === "boolean") update.archived = args.archived
|
||||||
|
|
||||||
|
const [updated] = await context.server.db
|
||||||
|
.update(tasks)
|
||||||
|
.set(update)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) throw new Error("Aufgabe nicht gefunden")
|
||||||
|
return { task: updated }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.tasks.archive",
|
||||||
|
title: "Aufgabe archivieren",
|
||||||
|
description: "Archiviert eine Aufgabe im aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["organisation.tasks.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const [updated] = await context.server.db
|
||||||
|
.update(tasks)
|
||||||
|
.set({
|
||||||
|
archived: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: context.userId,
|
||||||
|
})
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) throw new Error("Aufgabe nicht gefunden")
|
||||||
|
return { task: updated }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
36
backend/src/mcp/types.ts
Normal file
36
backend/src/mcp/types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest } from "fastify"
|
||||||
|
|
||||||
|
export type McpContext = {
|
||||||
|
server: FastifyInstance
|
||||||
|
request: FastifyRequest
|
||||||
|
tenantId: number
|
||||||
|
userId: string
|
||||||
|
isAdmin: boolean
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpToolResult = {
|
||||||
|
content: Array<{
|
||||||
|
type: "text"
|
||||||
|
text: string
|
||||||
|
}>
|
||||||
|
structuredContent?: Record<string, unknown>
|
||||||
|
isError?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpTool = {
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
requiredPermissions: string[]
|
||||||
|
inputSchema: Record<string, unknown>
|
||||||
|
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonRpcRequest = {
|
||||||
|
jsonrpc?: string
|
||||||
|
id?: string | number | null
|
||||||
|
method?: string
|
||||||
|
params?: any
|
||||||
|
}
|
||||||
|
|
||||||
490
backend/src/modules/bootstrap.service.ts
Normal file
490
backend/src/modules/bootstrap.service.ts
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import bcrypt from "bcrypt"
|
||||||
|
|
||||||
|
import {
|
||||||
|
accounts,
|
||||||
|
authProfiles,
|
||||||
|
authRoles,
|
||||||
|
authRolePermissions,
|
||||||
|
authTenantUsers,
|
||||||
|
authUserRoles,
|
||||||
|
authUsers,
|
||||||
|
branches,
|
||||||
|
filetags,
|
||||||
|
folders,
|
||||||
|
productcategories,
|
||||||
|
servicecategories,
|
||||||
|
taxTypes,
|
||||||
|
teams,
|
||||||
|
tenants,
|
||||||
|
texttemplates,
|
||||||
|
units,
|
||||||
|
} from "../../db/schema"
|
||||||
|
|
||||||
|
const adminPermissions = [
|
||||||
|
"mcp.tokens.write",
|
||||||
|
"staff.time.read_all",
|
||||||
|
"masterdata.customers.read",
|
||||||
|
"masterdata.vendors.read",
|
||||||
|
"masterdata.contacts.read",
|
||||||
|
"masterdata.products.read",
|
||||||
|
"masterdata.services.read",
|
||||||
|
"masterdata.cost_centres.read",
|
||||||
|
"masterdata.branches.read",
|
||||||
|
"masterdata.teams.read",
|
||||||
|
"masterdata.vehicles.read",
|
||||||
|
"masterdata.inventory_items.read",
|
||||||
|
"masterdata.units.read",
|
||||||
|
"accounting.outgoing_documents.read",
|
||||||
|
"accounting.outgoing_documents.write",
|
||||||
|
"accounting.accounts.read",
|
||||||
|
"accounting.incoming_invoices.read",
|
||||||
|
"accounting.incoming_invoices.write",
|
||||||
|
"accounting.bank.read",
|
||||||
|
"accounting.statement_allocations.read",
|
||||||
|
"organisation.customers.read",
|
||||||
|
"organisation.projects.read",
|
||||||
|
"organisation.plants.read",
|
||||||
|
"organisation.events.read",
|
||||||
|
"organisation.tasks.read",
|
||||||
|
"organisation.tasks.write",
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultUnits = [
|
||||||
|
{ name: "Stück", single: "Stück", multiple: "Stück", short: "Stk.", step: "1" },
|
||||||
|
{ name: "Stunde", single: "Stunde", multiple: "Stunden", short: "Std.", step: "0.25" },
|
||||||
|
{ name: "Pauschale", single: "Pauschale", multiple: "Pauschalen", short: "Psch.", step: "1" },
|
||||||
|
{ name: "Meter", single: "Meter", multiple: "Meter", short: "m", step: "0.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultTaxTypes = [
|
||||||
|
{ label: "Umsatzsteuer 19%", percentage: 19 },
|
||||||
|
{ label: "Umsatzsteuer 7%", percentage: 7 },
|
||||||
|
{ label: "Steuerfrei", percentage: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultAccounts = [
|
||||||
|
{ number: "8400", label: "Erlöse 19% USt", accountChart: "skr03" },
|
||||||
|
{ number: "8300", label: "Erlöse 7% USt", accountChart: "skr03" },
|
||||||
|
{ number: "1200", label: "Bank", accountChart: "skr03" },
|
||||||
|
{ number: "1000", label: "Kasse", accountChart: "skr03" },
|
||||||
|
{ number: "1400", label: "Forderungen aus Lieferungen und Leistungen", accountChart: "skr03" },
|
||||||
|
{ number: "1600", label: "Verbindlichkeiten aus Lieferungen und Leistungen", accountChart: "skr03" },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function ensureGlobalDefaults(server: FastifyInstance, userId: string) {
|
||||||
|
for (const unit of defaultUnits) {
|
||||||
|
const existing = await server.db.select({ id: units.id }).from(units).where(eq(units.name, unit.name)).limit(1)
|
||||||
|
if (!existing.length) await server.db.insert(units).values(unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const taxType of defaultTaxTypes) {
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: taxTypes.id })
|
||||||
|
.from(taxTypes)
|
||||||
|
.where(eq(taxTypes.percentage, taxType.percentage))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(taxTypes).values({
|
||||||
|
...taxType,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const account of defaultAccounts) {
|
||||||
|
const existing = await server.db
|
||||||
|
.select({ id: accounts.id })
|
||||||
|
.from(accounts)
|
||||||
|
.where(and(eq(accounts.accountChart, account.accountChart), eq(accounts.number, account.number)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await server.db.insert(accounts).values({
|
||||||
|
...account,
|
||||||
|
description: "FEDEO Standardkonto",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: number, userId: string) {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const timestamp = new Date()
|
||||||
|
|
||||||
|
const tagDefaults = [
|
||||||
|
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices" },
|
||||||
|
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes" },
|
||||||
|
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders" },
|
||||||
|
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes" },
|
||||||
|
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices" },
|
||||||
|
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders" },
|
||||||
|
]
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
1425
backend/src/modules/matrix.service.ts
Normal file
1425
backend/src/modules/matrix.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,51 @@
|
|||||||
// 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,
|
||||||
|
notificationPushSubscriptions,
|
||||||
|
notificationsEventTypes,
|
||||||
|
notificationsItems,
|
||||||
|
notificationsPreferences,
|
||||||
|
notificationsPreferencesDefaults,
|
||||||
|
} from "../../db/schema"
|
||||||
|
import { secrets } from "../utils/secrets"
|
||||||
|
|
||||||
export type NotificationStatus = 'queued' | 'sent' | 'failed';
|
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
|
||||||
|
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
|
||||||
|
|
||||||
export interface TriggerInput {
|
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 +53,355 @@ 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) {
|
||||||
|
if (!secrets.WEB_PUSH_PUBLIC_KEY || !secrets.WEB_PUSH_PRIVATE_KEY) {
|
||||||
|
await this.markFailed(item.id, "Web Push ist nicht konfiguriert")
|
||||||
|
return { success: false, id: item.id, channel: item.channel }
|
||||||
|
}
|
||||||
|
|
||||||
|
webPush.setVapidDetails(
|
||||||
|
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
|
||||||
|
secrets.WEB_PUSH_PUBLIC_KEY,
|
||||||
|
secrets.WEB_PUSH_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
const subscriptions = await this.server.db
|
||||||
|
.select()
|
||||||
|
.from(notificationPushSubscriptions)
|
||||||
|
.where(and(
|
||||||
|
eq(notificationPushSubscriptions.tenantId, item.tenantId),
|
||||||
|
eq(notificationPushSubscriptions.userId, item.userId),
|
||||||
|
isNull(notificationPushSubscriptions.disabledAt)
|
||||||
|
))
|
||||||
|
|
||||||
|
if (!subscriptions.length) {
|
||||||
|
await this.markFailed(item.id, "Keine aktive Push-Subscription")
|
||||||
|
return { success: false, id: item.id, channel: item.channel }
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
message: item.message,
|
||||||
|
payload: item.payload || {},
|
||||||
|
})
|
||||||
|
|
||||||
|
let delivered = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
try {
|
||||||
|
await webPush.sendNotification({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: subscription.p256dh,
|
||||||
|
auth: subscription.auth,
|
||||||
|
},
|
||||||
|
}, payload)
|
||||||
|
|
||||||
|
delivered++
|
||||||
|
await this.server.db
|
||||||
|
.update(notificationPushSubscriptions)
|
||||||
|
.set({ lastSeenAt: new Date() })
|
||||||
|
.where(eq(notificationPushSubscriptions.id, subscription.id))
|
||||||
|
} catch (error: any) {
|
||||||
|
errors.push(error?.message || String(error))
|
||||||
|
|
||||||
|
if (error?.statusCode === 404 || error?.statusCode === 410) {
|
||||||
|
await this.server.db
|
||||||
|
.update(notificationPushSubscriptions)
|
||||||
|
.set({ disabledAt: new Date() })
|
||||||
|
.where(eq(notificationPushSubscriptions.id, subscription.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delivered > 0) {
|
||||||
|
await this.markSent(item.id)
|
||||||
|
return { success: true, id: item.id, channel: item.channel, delivered }
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.markFailed(item.id, errors.join("; ") || "Push konnte nicht zugestellt werden")
|
||||||
|
return { success: false, id: item.id, channel: item.channel }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverEmail(item: typeof notificationsItems.$inferSelect) {
|
||||||
try {
|
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 +415,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/>")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ export type DerivedSpan = {
|
|||||||
sourceEventIds: string[];
|
sourceEventIds: string[];
|
||||||
status: SpanStatus;
|
status: SpanStatus;
|
||||||
statusActorId?: string;
|
statusActorId?: string;
|
||||||
|
payload?: Record<string, any> | null;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TimeEvent = {
|
type TimeEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
eventtype: string;
|
eventtype: string;
|
||||||
eventtime: Date;
|
eventtime: Date;
|
||||||
|
payload?: Record<string, any> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
|
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
|
||||||
@@ -45,9 +48,17 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
let currentStart: Date | null = null;
|
let currentStart: Date | null = null;
|
||||||
let currentType: DerivedSpan["type"] | null = null;
|
let currentType: DerivedSpan["type"] | null = null;
|
||||||
let sourceEventIds: string[] = [];
|
let sourceEventIds: string[] = [];
|
||||||
|
let currentPayload: Record<string, any> | null = null;
|
||||||
|
|
||||||
const closeSpan = (end: Date) => {
|
const closeSpan = (end: Date) => {
|
||||||
if (!currentStart || !currentType) return;
|
if (!currentStart || !currentType) return;
|
||||||
|
if (end.getTime() <= currentStart.getTime()) {
|
||||||
|
currentStart = null;
|
||||||
|
currentType = null;
|
||||||
|
sourceEventIds = [];
|
||||||
|
currentPayload = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
spans.push({
|
spans.push({
|
||||||
type: currentType,
|
type: currentType,
|
||||||
@@ -55,12 +66,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
endedAt: end,
|
endedAt: end,
|
||||||
sourceEventIds: [...sourceEventIds],
|
sourceEventIds: [...sourceEventIds],
|
||||||
// Standardstatus ist "factual", wird später angereichert
|
// Standardstatus ist "factual", wird später angereichert
|
||||||
status: "factual"
|
status: "factual",
|
||||||
|
payload: currentPayload,
|
||||||
|
description: currentPayload?.description || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
currentStart = null;
|
currentStart = null;
|
||||||
currentType = null;
|
currentType = null;
|
||||||
sourceEventIds = [];
|
sourceEventIds = [];
|
||||||
|
currentPayload = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeOpenSpanAsRunning = () => {
|
const closeOpenSpanAsRunning = () => {
|
||||||
@@ -72,12 +86,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
endedAt: null,
|
endedAt: null,
|
||||||
sourceEventIds: [...sourceEventIds],
|
sourceEventIds: [...sourceEventIds],
|
||||||
// Standardstatus ist "factual", wird später angereichert
|
// Standardstatus ist "factual", wird später angereichert
|
||||||
status: "factual"
|
status: "factual",
|
||||||
|
payload: currentPayload,
|
||||||
|
description: currentPayload?.description || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
currentStart = null;
|
currentStart = null;
|
||||||
currentType = null;
|
currentType = null;
|
||||||
sourceEventIds = [];
|
sourceEventIds = [];
|
||||||
|
currentPayload = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -96,6 +113,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
state = "WORKING";
|
state = "WORKING";
|
||||||
currentStart = event.eventtime;
|
currentStart = event.eventtime;
|
||||||
currentType = "work";
|
currentType = "work";
|
||||||
|
currentPayload = event.payload || null;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "pause_start":
|
case "pause_start":
|
||||||
@@ -104,6 +122,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
state = "PAUSED";
|
state = "PAUSED";
|
||||||
currentStart = event.eventtime;
|
currentStart = event.eventtime;
|
||||||
currentType = "pause";
|
currentType = "pause";
|
||||||
|
currentPayload = event.payload || null;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -113,6 +132,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
state = "WORKING";
|
state = "WORKING";
|
||||||
currentStart = event.eventtime;
|
currentStart = event.eventtime;
|
||||||
currentType = "work";
|
currentType = "work";
|
||||||
|
currentPayload = event.payload || null;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -140,6 +160,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
state = "ABSENT";
|
state = "ABSENT";
|
||||||
currentStart = event.eventtime;
|
currentStart = event.eventtime;
|
||||||
currentType = newType;
|
currentType = newType;
|
||||||
|
currentPayload = event.payload || null;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "vacation_end":
|
case "vacation_end":
|
||||||
@@ -162,4 +183,4 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return spans;
|
return spans;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/services/loadValidEvents.ts
|
// src/services/loadValidEvents.ts
|
||||||
|
|
||||||
import { stafftimeevents } from "../../../db/schema";
|
import { stafftimeevents } from "../../../db/schema";
|
||||||
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
|
import {sql, and, eq, gte, lte, inArray, asc} from "drizzle-orm";
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
|
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
|
||||||
@@ -12,11 +12,43 @@ export type TimeEvent = {
|
|||||||
id: string;
|
id: string;
|
||||||
eventtype: string;
|
eventtype: string;
|
||||||
eventtime: Date;
|
eventtime: Date;
|
||||||
actoruser_id: string;
|
actoruser_id?: string;
|
||||||
related_event_id: string | null;
|
related_event_id: string | null;
|
||||||
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
|
payload?: Record<string, any> | null;
|
||||||
|
created_at?: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EVENT_TYPE_ORDER: Record<string, number> = {
|
||||||
|
auto_stop: 10,
|
||||||
|
work_end: 10,
|
||||||
|
pause_end: 10,
|
||||||
|
vacation_end: 10,
|
||||||
|
sick_end: 10,
|
||||||
|
overtime_compensation_end: 10,
|
||||||
|
work_start: 20,
|
||||||
|
pause_start: 20,
|
||||||
|
vacation_start: 20,
|
||||||
|
sick_start: 20,
|
||||||
|
overtime_compensation_start: 20,
|
||||||
|
submitted: 30,
|
||||||
|
approved: 30,
|
||||||
|
rejected: 30,
|
||||||
|
invalidated: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compareTimeEvents(a: TimeEvent, b: TimeEvent) {
|
||||||
|
const eventTimeDiff = a.eventtime.getTime() - b.eventtime.getTime();
|
||||||
|
if (eventTimeDiff !== 0) return eventTimeDiff;
|
||||||
|
|
||||||
|
const typeOrderDiff = (EVENT_TYPE_ORDER[a.eventtype] ?? 999) - (EVENT_TYPE_ORDER[b.eventtype] ?? 999);
|
||||||
|
if (typeOrderDiff !== 0) return typeOrderDiff;
|
||||||
|
|
||||||
|
const createdAtDiff = (a.created_at?.getTime() ?? 0) - (b.created_at?.getTime() ?? 0);
|
||||||
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
|
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadValidEvents(
|
export async function loadValidEvents(
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
@@ -62,10 +94,9 @@ export async function loadValidEvents(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(
|
.orderBy(
|
||||||
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
|
asc(baseEvents.eventtime),
|
||||||
baseEvents.eventtime,
|
asc(baseEvents.created_at),
|
||||||
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
|
asc(baseEvents.id)
|
||||||
baseEvents.id
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mapping auf den sauberen TimeEvent Typ
|
// Mapping auf den sauberen TimeEvent Typ
|
||||||
@@ -73,8 +104,10 @@ export async function loadValidEvents(
|
|||||||
id: e.id,
|
id: e.id,
|
||||||
eventtype: e.eventtype,
|
eventtype: e.eventtype,
|
||||||
eventtime: e.eventtime,
|
eventtime: e.eventtime,
|
||||||
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
|
actoruser_id: e.actoruser_id,
|
||||||
// ...
|
related_event_id: e.related_event_id,
|
||||||
|
payload: e.payload,
|
||||||
|
created_at: e.created_at,
|
||||||
})) as TimeEvent[];
|
})) as TimeEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
|
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
|
||||||
.orderBy(stafftimeevents.eventtime);
|
.orderBy(
|
||||||
|
asc(stafftimeevents.eventtime),
|
||||||
|
asc(stafftimeevents.created_at),
|
||||||
|
asc(stafftimeevents.id)
|
||||||
|
);
|
||||||
|
|
||||||
return adminEvents;
|
return adminEvents as TimeEvent[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,67 @@ import { FastifyInstance } from "fastify"
|
|||||||
import fp from "fastify-plugin"
|
import fp from "fastify-plugin"
|
||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken"
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authUserRoles,
|
authUserRoles,
|
||||||
authRolePermissions,
|
authRolePermissions,
|
||||||
authUsers,
|
authUsers,
|
||||||
|
m2mApiKeys,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
import { eq, and } from "drizzle-orm"
|
import { eq, and, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
export default fp(async (server: FastifyInstance) => {
|
export default fp(async (server: FastifyInstance) => {
|
||||||
|
const hashApiKey = (apiKey: string) =>
|
||||||
|
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||||||
|
|
||||||
|
const isMcpRoute = (url: string) =>
|
||||||
|
url === "/mcp" ||
|
||||||
|
url.startsWith("/mcp/") ||
|
||||||
|
url === "/api/mcp" ||
|
||||||
|
url.startsWith("/api/mcp/")
|
||||||
|
|
||||||
|
const authenticateMcpApiKey = async (apiKey: string) => {
|
||||||
|
if (!apiKey.startsWith("fedeo_mcp_")) return false
|
||||||
|
|
||||||
|
const keyHash = hashApiKey(apiKey)
|
||||||
|
|
||||||
|
const rows = await server.db
|
||||||
|
.select({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
tenantId: m2mApiKeys.tenantId,
|
||||||
|
userId: m2mApiKeys.userId,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
expiresAt: m2mApiKeys.expiresAt,
|
||||||
|
userEmail: authUsers.email,
|
||||||
|
isAdmin: authUsers.is_admin,
|
||||||
|
})
|
||||||
|
.from(m2mApiKeys)
|
||||||
|
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.keyHash, keyHash),
|
||||||
|
eq(m2mApiKeys.active, true)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const key = rows[0]
|
||||||
|
if (!key) return false
|
||||||
|
if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) return false
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(m2mApiKeys)
|
||||||
|
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(m2mApiKeys.id, key.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: key.userId,
|
||||||
|
email: key.userEmail,
|
||||||
|
tenant_id: key.tenantId,
|
||||||
|
is_admin: Boolean(key.isAdmin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server.addHook("preHandler", async (req, reply) => {
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
// 1️⃣ Token aus Header oder Cookie lesen
|
// 1️⃣ Token aus Header oder Cookie lesen
|
||||||
const cookieToken = req.cookies?.token
|
const cookieToken = req.cookies?.token
|
||||||
@@ -30,11 +81,31 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2️⃣ JWT verifizieren
|
// 2️⃣ JWT verifizieren oder für MCP dauerhaften Token akzeptieren
|
||||||
const payload = jwt.verify(token, secrets.JWT_SECRET!) as {
|
let payload: {
|
||||||
user_id: string
|
user_id: string
|
||||||
email: string
|
email: string
|
||||||
tenant_id: number | null
|
tenant_id: number | null
|
||||||
|
is_admin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, secrets.JWT_SECRET!) as {
|
||||||
|
user_id: string
|
||||||
|
email: string
|
||||||
|
tenant_id: number | null
|
||||||
|
}
|
||||||
|
} catch (jwtError) {
|
||||||
|
const mcpPayload = isMcpRoute(req.url)
|
||||||
|
? await authenticateMcpApiKey(token)
|
||||||
|
: false
|
||||||
|
|
||||||
|
if (!mcpPayload) {
|
||||||
|
throw jwtError
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = mcpPayload
|
||||||
|
;(req as any).mcpTokenAuth = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload?.user_id) {
|
if (!payload?.user_id) {
|
||||||
@@ -44,15 +115,19 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
// Payload an Request hängen
|
// Payload an Request hängen
|
||||||
req.user = payload
|
req.user = payload
|
||||||
|
|
||||||
const [currentUser] = await server.db
|
if (typeof payload.is_admin === "boolean") {
|
||||||
.select({
|
req.user.is_admin = payload.is_admin
|
||||||
is_admin: authUsers.is_admin,
|
} else {
|
||||||
})
|
const [currentUser] = await server.db
|
||||||
.from(authUsers)
|
.select({
|
||||||
.where(eq(authUsers.id, payload.user_id))
|
is_admin: authUsers.is_admin,
|
||||||
.limit(1)
|
})
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.id, payload.user_id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
req.user.is_admin = Boolean(currentUser?.is_admin)
|
req.user.is_admin = Boolean(currentUser?.is_admin)
|
||||||
|
}
|
||||||
|
|
||||||
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
||||||
if (!req.user.tenant_id) {
|
if (!req.user.tenant_id) {
|
||||||
@@ -63,10 +138,12 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
const userId = req.user.user_id
|
const userId = req.user.user_id
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// 3️⃣ Rolle des Nutzers im Tenant holen
|
// 3️⃣ Rollen des Nutzers im Tenant holen
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
const roleRows = await server.db
|
const roleRows = await server.db
|
||||||
.select()
|
.select({
|
||||||
|
role_id: authUserRoles.role_id,
|
||||||
|
})
|
||||||
.from(authUserRoles)
|
.from(authUserRoles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -74,7 +151,6 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
eq(authUserRoles.tenant_id, tenantId)
|
eq(authUserRoles.tenant_id, tenantId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (roleRows.length === 0) {
|
if (roleRows.length === 0) {
|
||||||
if (req.user.is_admin) {
|
if (req.user.is_admin) {
|
||||||
@@ -89,22 +165,22 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
.send({ error: "No role assigned for this tenant" })
|
.send({ error: "No role assigned for this tenant" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleId = roleRows[0].role_id
|
const roleIds = Array.from(new Set(roleRows.map((role) => role.role_id)))
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// 4️⃣ Berechtigungen der Rolle laden
|
// 4️⃣ Berechtigungen der Rollen laden
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
const permissionRows = await server.db
|
const permissionRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authRolePermissions)
|
.from(authRolePermissions)
|
||||||
.where(eq(authRolePermissions.role_id, roleId))
|
.where(inArray(authRolePermissions.role_id, roleIds))
|
||||||
|
|
||||||
const permissions = permissionRows.map((p) => p.permission)
|
const permissions = Array.from(new Set(permissionRows.map((p) => p.permission)))
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// 5️⃣ An Request hängen für spätere Nutzung
|
// 5️⃣ An Request hängen für spätere Nutzung
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
req.role = roleId
|
req.role = roleIds[0]
|
||||||
req.permissions = permissions
|
req.permissions = permissions
|
||||||
req.hasPermission = (perm: string) => permissions.includes(perm)
|
req.hasPermission = (perm: string) => permissions.includes(perm)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
authTenantUsers,
|
authTenantUsers,
|
||||||
authProfiles,
|
authProfiles,
|
||||||
|
customers,
|
||||||
authRoles,
|
authRoles,
|
||||||
authUserRoles,
|
authUserRoles,
|
||||||
authUsers,
|
authUsers,
|
||||||
@@ -12,6 +13,10 @@ import {
|
|||||||
tenants,
|
tenants,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
import { generateRandomPassword, hashPassword } from "../utils/password";
|
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||||
|
import { sendMail } from "../utils/mailer";
|
||||||
|
import { ensureTenantBaseData } from "../modules/bootstrap.service";
|
||||||
|
import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport";
|
||||||
|
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||||
|
|
||||||
export default async function adminRoutes(server: FastifyInstance) {
|
export default async function adminRoutes(server: FastifyInstance) {
|
||||||
const deriveNameFromEmail = (email: string) => {
|
const deriveNameFromEmail = (email: string) => {
|
||||||
@@ -241,6 +246,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)
|
||||||
@@ -255,6 +261,33 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
return currentUser;
|
return currentUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
|
||||||
|
const existingRoles = await server.db
|
||||||
|
.select({
|
||||||
|
id: authRoles.id,
|
||||||
|
name: authRoles.name,
|
||||||
|
})
|
||||||
|
.from(authRoles)
|
||||||
|
.where(eq(authRoles.tenant_id, tenantId));
|
||||||
|
|
||||||
|
const portalRole = existingRoles.find((role) => role.name === "Kundenportal");
|
||||||
|
if (portalRole) return portalRole.id;
|
||||||
|
|
||||||
|
const [createdRole] = await server.db
|
||||||
|
.insert(authRoles)
|
||||||
|
.values({
|
||||||
|
name: "Kundenportal",
|
||||||
|
description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer",
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_by: createdBy,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: authRoles.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdRole.id;
|
||||||
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// GET /admin/overview
|
// GET /admin/overview
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -422,6 +455,343 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// POST /admin/profiles/:profileId/create-user
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.post("/admin/profiles/:profileId/create-user", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const { profileId } = req.params as { profileId: string };
|
||||||
|
const body = req.body as { email?: string };
|
||||||
|
|
||||||
|
const email = body.email?.trim().toLowerCase();
|
||||||
|
if (!email) {
|
||||||
|
return reply.code(400).send({ error: "email required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [profile] = await server.db
|
||||||
|
.select({
|
||||||
|
id: authProfiles.id,
|
||||||
|
tenant_id: authProfiles.tenant_id,
|
||||||
|
user_id: authProfiles.user_id,
|
||||||
|
first_name: authProfiles.first_name,
|
||||||
|
last_name: authProfiles.last_name,
|
||||||
|
email: authProfiles.email,
|
||||||
|
})
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(eq(authProfiles.id, profileId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return reply.code(404).send({ error: "Profile not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.user_id) {
|
||||||
|
return reply.code(409).send({ error: "Profile already linked to a user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUsers = await server.db
|
||||||
|
.select({ id: authUsers.id })
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUsers.length) {
|
||||||
|
return reply.code(409).send({ error: "User with this email already exists" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialPassword = generateRandomPassword(14);
|
||||||
|
const passwordHash = await hashPassword(initialPassword);
|
||||||
|
|
||||||
|
const result = await server.db.transaction(async (tx) => {
|
||||||
|
const [createdUser] = await tx
|
||||||
|
.insert(authUsers)
|
||||||
|
.values({
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
is_admin: false,
|
||||||
|
multiTenant: true,
|
||||||
|
must_change_password: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: authUsers.id,
|
||||||
|
email: authUsers.email,
|
||||||
|
must_change_password: authUsers.must_change_password,
|
||||||
|
is_admin: authUsers.is_admin,
|
||||||
|
multiTenant: authUsers.multiTenant,
|
||||||
|
created_at: authUsers.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(authTenantUsers)
|
||||||
|
.values({
|
||||||
|
tenant_id: profile.tenant_id,
|
||||||
|
user_id: createdUser.id,
|
||||||
|
created_by: currentUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updatedProfile] = await tx
|
||||||
|
.update(authProfiles)
|
||||||
|
.set({
|
||||||
|
user_id: createdUser.id,
|
||||||
|
email,
|
||||||
|
})
|
||||||
|
.where(eq(authProfiles.id, profile.id))
|
||||||
|
.returning({
|
||||||
|
id: authProfiles.id,
|
||||||
|
tenant_id: authProfiles.tenant_id,
|
||||||
|
user_id: authProfiles.user_id,
|
||||||
|
first_name: authProfiles.first_name,
|
||||||
|
last_name: authProfiles.last_name,
|
||||||
|
email: authProfiles.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: createdUser,
|
||||||
|
profile: updatedProfile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
initialPassword,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/profiles/:profileId/create-user:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const tenantId = Number(req.user?.tenant_id);
|
||||||
|
const { customerId } = req.params as { customerId: string };
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tenantRecord] = await server.db
|
||||||
|
.select({
|
||||||
|
id: tenants.id,
|
||||||
|
name: tenants.name,
|
||||||
|
portalDomain: tenants.portalDomain,
|
||||||
|
})
|
||||||
|
.from(tenants)
|
||||||
|
.where(eq(tenants.id, tenantId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [customerRecord] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!customerRecord) {
|
||||||
|
return reply.code(404).send({ error: "Customer not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record<string, any> : {};
|
||||||
|
const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return reply.code(400).send({ error: "Customer has no email address" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedPassword = generateRandomPassword(14);
|
||||||
|
const passwordHash = await hashPassword(generatedPassword);
|
||||||
|
|
||||||
|
const [existingUser] = await server.db
|
||||||
|
.select({
|
||||||
|
id: authUsers.id,
|
||||||
|
email: authUsers.email,
|
||||||
|
is_admin: authUsers.is_admin,
|
||||||
|
})
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const derivedName = deriveNameFromEmail(email);
|
||||||
|
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
|
||||||
|
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
|
||||||
|
|
||||||
|
let userId = existingUser?.id || null;
|
||||||
|
let createdNewUser = false;
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
const [existingProfile] = await server.db
|
||||||
|
.select({
|
||||||
|
id: authProfiles.id,
|
||||||
|
customer_for_portal: authProfiles.customer_for_portal,
|
||||||
|
})
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.user_id, existingUser.id),
|
||||||
|
eq(authProfiles.tenant_id, tenantId)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUser.is_admin) {
|
||||||
|
return reply.code(409).send({ error: "Email address is already used by an admin user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingProfile) {
|
||||||
|
return reply.code(409).send({ error: "Email address is already used by another user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) {
|
||||||
|
return reply.code(409).send({ error: "Email address is already assigned to another portal customer" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(authUsers)
|
||||||
|
.set({
|
||||||
|
passwordHash,
|
||||||
|
must_change_password: true,
|
||||||
|
multiTenant: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(authUsers.id, existingUser.id));
|
||||||
|
|
||||||
|
userId = existingUser.id;
|
||||||
|
} else {
|
||||||
|
const [createdUser] = await server.db
|
||||||
|
.insert(authUsers)
|
||||||
|
.values({
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
is_admin: false,
|
||||||
|
multiTenant: false,
|
||||||
|
must_change_password: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: authUsers.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
userId = createdUser.id;
|
||||||
|
createdNewUser = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id);
|
||||||
|
|
||||||
|
const existingMemberships = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authTenantUsers)
|
||||||
|
.where(and(
|
||||||
|
eq(authTenantUsers.user_id, userId!),
|
||||||
|
eq(authTenantUsers.tenant_id, tenantId)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingMemberships.length) {
|
||||||
|
await server.db
|
||||||
|
.insert(authTenantUsers)
|
||||||
|
.values({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: userId!,
|
||||||
|
created_by: currentUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPortalRoleAssignment = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authUserRoles)
|
||||||
|
.where(and(
|
||||||
|
eq(authUserRoles.user_id, userId!),
|
||||||
|
eq(authUserRoles.tenant_id, tenantId),
|
||||||
|
eq(authUserRoles.role_id, portalRoleId)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingPortalRoleAssignment.length) {
|
||||||
|
await server.db
|
||||||
|
.insert(authUserRoles)
|
||||||
|
.values({
|
||||||
|
user_id: userId!,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
role_id: portalRoleId,
|
||||||
|
created_by: currentUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingTenantProfile] = await server.db
|
||||||
|
.select({
|
||||||
|
id: authProfiles.id,
|
||||||
|
user_id: authProfiles.user_id,
|
||||||
|
customer_for_portal: authProfiles.customer_for_portal,
|
||||||
|
})
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.user_id, userId!),
|
||||||
|
eq(authProfiles.tenant_id, tenantId)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingTenantProfile) {
|
||||||
|
await server.db
|
||||||
|
.update(authProfiles)
|
||||||
|
.set({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
email,
|
||||||
|
customer_for_portal: customerRecord.id,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
.where(eq(authProfiles.id, existingTenantProfile.id));
|
||||||
|
} else {
|
||||||
|
await server.db
|
||||||
|
.insert(authProfiles)
|
||||||
|
.values({
|
||||||
|
user_id: userId!,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
email,
|
||||||
|
customer_for_portal: customerRecord.id,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
|
||||||
|
|
||||||
|
const mailResult = await sendMail(
|
||||||
|
email,
|
||||||
|
`FEDEO | Einladung ins Kundenportal`,
|
||||||
|
`
|
||||||
|
<p>Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},</p>
|
||||||
|
<p>für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.</p>
|
||||||
|
<p><strong>E-Mail:</strong> ${email}</p>
|
||||||
|
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
|
||||||
|
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
|
||||||
|
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
|
||||||
|
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mailResult.success) {
|
||||||
|
return reply.code(500).send({ error: "Invitation email could not be sent" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
createdNewUser,
|
||||||
|
email,
|
||||||
|
initialPassword: generatedPassword,
|
||||||
|
portalUrl,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// POST /admin/tenants
|
// POST /admin/tenants
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -459,6 +829,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) {
|
||||||
@@ -561,6 +932,96 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GET /admin/tenants/:tenant_id/export
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/admin/tenants/:tenant_id/export", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const { tenant_id } = req.params as { tenant_id: string };
|
||||||
|
const tenantId = Number(tenant_id);
|
||||||
|
if (!tenantId) {
|
||||||
|
return reply.code(400).send({ error: "tenant_id required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData = await buildTenantFullExport(server, tenantId);
|
||||||
|
const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId)
|
||||||
|
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
const filename = `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
|
||||||
|
reply.header("Content-Type", "application/json");
|
||||||
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
return reply.send(exportData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("ERROR /admin/tenants/:tenant_id/export:", err);
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// POST /admin/tenant-imports
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const currentUser = await requireAdmin(req, reply);
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
|
||||||
|
const exportData = "format" in body ? body : body.exportData;
|
||||||
|
const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null;
|
||||||
|
|
||||||
|
if (!exportData) {
|
||||||
|
return reply.code(400).send({ error: "exportData required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await importTenantFullExport(server, exportData, { targetTenantId });
|
||||||
|
const fallbackName = deriveNameFromEmail(currentUser.email);
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.insert(authTenantUsers)
|
||||||
|
.values({
|
||||||
|
tenant_id: result.tenantId,
|
||||||
|
user_id: currentUser.id,
|
||||||
|
created_by: currentUser.id,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
const [existingAdminProfile] = await server.db
|
||||||
|
.select({ id: authProfiles.id })
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.tenant_id, result.tenantId),
|
||||||
|
eq(authProfiles.user_id, currentUser.id)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingAdminProfile) {
|
||||||
|
await server.db
|
||||||
|
.insert(authProfiles)
|
||||||
|
.values({
|
||||||
|
tenant_id: result.tenantId,
|
||||||
|
user_id: currentUser.id,
|
||||||
|
first_name: fallbackName.first_name,
|
||||||
|
last_name: fallbackName.last_name,
|
||||||
|
email: currentUser.email,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("ERROR /admin/tenant-imports:", err);
|
||||||
|
return reply.code(500).send({ error: err?.message || "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// PUT /admin/users/:user_id/access
|
// PUT /admin/users/:user_id/access
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
authRolePermissions,
|
authRolePermissions,
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
import { eq, and, or, isNull } from "drizzle-orm"
|
import { eq, and, or, isNull } from "drizzle-orm"
|
||||||
|
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||||
|
|
||||||
export default async function meRoutes(server: FastifyInstance) {
|
export default async function meRoutes(server: FastifyInstance) {
|
||||||
server.get("/me", async (req, reply) => {
|
server.get("/me", async (req, reply) => {
|
||||||
@@ -51,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,
|
||||||
|
hasActiveLicense: tenants.hasActiveLicense,
|
||||||
locked: tenants.locked,
|
locked: tenants.locked,
|
||||||
features: tenants.features,
|
features: tenants.features,
|
||||||
extraModules: tenants.extraModules,
|
extraModules: tenants.extraModules,
|
||||||
@@ -89,7 +91,8 @@ export default async function meRoutes(server: FastifyInstance) {
|
|||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
profile = profileResult?.[0] ?? null
|
const enrichedProfiles = await enrichProfilesWithBranches(server, profileResult)
|
||||||
|
profile = enrichedProfiles?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
|
bankaccounts,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
|
accounts,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
customers,
|
customers,
|
||||||
entitybankaccounts,
|
entitybankaccounts,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
|
ownaccounts,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
vendors,
|
vendors,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
@@ -22,10 +25,355 @@ import {
|
|||||||
import {
|
import {
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
|
isNull,
|
||||||
|
aliasedTable,
|
||||||
|
desc,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
export default async function bankingRoutes(server: FastifyInstance) {
|
export default async function bankingRoutes(server: FastifyInstance) {
|
||||||
|
const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
||||||
|
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
|
||||||
|
const ContraCustomers = aliasedTable(customers, "contra_customers")
|
||||||
|
const ContraVendors = aliasedTable(vendors, "contra_vendors")
|
||||||
|
const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts")
|
||||||
|
const ManualInvoices = aliasedTable(incominginvoices, "manual_invoices")
|
||||||
|
const ManualInvoiceVendors = aliasedTable(vendors, "manual_invoice_vendors")
|
||||||
|
|
||||||
|
const normalizeManualSide = (payload: any, keys: string[]) =>
|
||||||
|
keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "")
|
||||||
|
|
||||||
|
const cashbookAccountFilter = (tenantId: number) => and(
|
||||||
|
eq(bankaccounts.tenant, tenantId),
|
||||||
|
eq(bankaccounts.bankId, CASHBOOK_BANK_ID),
|
||||||
|
eq(bankaccounts.archived, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildCashbookCounterPayload = (type: string, id: any) => {
|
||||||
|
const numericId = type === "ownaccount" ? id : Number(id)
|
||||||
|
if (!id || (type !== "ownaccount" && !Number.isFinite(numericId))) return null
|
||||||
|
|
||||||
|
if (type === "account") return { account: numericId }
|
||||||
|
if (type === "customer") return { customer: numericId }
|
||||||
|
if (type === "vendor") return { vendor: numericId }
|
||||||
|
if (type === "ownaccount") return { ownaccount: numericId }
|
||||||
|
if (type === "incominginvoice") return { incominginvoice: numericId }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareStatementAllocationPayload = (payload: any) => {
|
||||||
|
const next = { ...payload }
|
||||||
|
const isManualBooking = !next.bankstatement
|
||||||
|
|
||||||
|
if (!isManualBooking) {
|
||||||
|
next.manualBookingDate = null
|
||||||
|
next.contraAccount = null
|
||||||
|
next.contraCustomer = null
|
||||||
|
next.contraVendor = null
|
||||||
|
next.contraOwnaccount = null
|
||||||
|
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
|
||||||
|
next.manualInvoiceSide = null
|
||||||
|
return { data: next }
|
||||||
|
}
|
||||||
|
|
||||||
|
const debitKeys = ["account", "customer", "vendor", "ownaccount"]
|
||||||
|
const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"]
|
||||||
|
const hasManualInvoice = next.incominginvoice !== null && next.incominginvoice !== undefined && next.incominginvoice !== ""
|
||||||
|
const debitSide = normalizeManualSide(next, debitKeys)
|
||||||
|
const creditSide = normalizeManualSide(next, creditKeys)
|
||||||
|
|
||||||
|
if (hasManualInvoice) {
|
||||||
|
if (next.manualInvoiceSide === "debit") debitSide.push("incominginvoice")
|
||||||
|
else if (next.manualInvoiceSide === "credit") creditSide.push("incominginvoice")
|
||||||
|
else return { error: "Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein." }
|
||||||
|
} else {
|
||||||
|
next.manualInvoiceSide = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) {
|
||||||
|
return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) {
|
||||||
|
return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debitSide.length !== 1 || creditSide.length !== 1) {
|
||||||
|
return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." }
|
||||||
|
}
|
||||||
|
|
||||||
|
next.amount = Math.abs(Number(next.amount))
|
||||||
|
next.bankstatement = null
|
||||||
|
next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD")
|
||||||
|
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
|
||||||
|
|
||||||
|
return { data: next }
|
||||||
|
}
|
||||||
|
|
||||||
|
server.get("/banking/cashbooks", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const rows = await server.db
|
||||||
|
.select()
|
||||||
|
.from(bankaccounts)
|
||||||
|
.where(cashbookAccountFilter(req.user.tenant_id))
|
||||||
|
.orderBy(bankaccounts.name)
|
||||||
|
|
||||||
|
return reply.send(rows)
|
||||||
|
} catch (err) {
|
||||||
|
server.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to load cashbooks" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/banking/cashbooks", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const body = req.body as {
|
||||||
|
name?: string
|
||||||
|
datevNumber?: string
|
||||||
|
openingBalance?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = String(body.name || "").trim()
|
||||||
|
const datevNumber = String(body.datevNumber || "").trim()
|
||||||
|
const openingBalance = Number(body.openingBalance || 0)
|
||||||
|
|
||||||
|
if (!name) return reply.code(400).send({ error: "Bitte eine Bezeichnung für die Kasse angeben." })
|
||||||
|
if (!datevNumber) return reply.code(400).send({ error: "Bitte eine Kontennummer für die Kasse angeben." })
|
||||||
|
if (!Number.isFinite(openingBalance)) return reply.code(400).send({ error: "Der Anfangsbestand ist ungültig." })
|
||||||
|
|
||||||
|
const uniquePart = `${req.user.tenant_id}-${Date.now()}`
|
||||||
|
const inserted = await server.db.insert(bankaccounts).values({
|
||||||
|
name,
|
||||||
|
iban: `CASH-${uniquePart}`,
|
||||||
|
tenant: req.user.tenant_id,
|
||||||
|
bankId: CASHBOOK_BANK_ID,
|
||||||
|
ownerName: name,
|
||||||
|
accountId: `cashbook-${uniquePart}`,
|
||||||
|
balance: openingBalance,
|
||||||
|
datevNumber,
|
||||||
|
updatedBy: req.user.user_id,
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
const createdRecord = inserted[0]
|
||||||
|
return reply.send(createdRecord)
|
||||||
|
} catch (err) {
|
||||||
|
server.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to create cashbook" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.patch("/banking/cashbooks/:id/archive", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const updated = await server.db.update(bankaccounts)
|
||||||
|
.set({
|
||||||
|
archived: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: req.user.user_id,
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(bankaccounts.id, Number(id)),
|
||||||
|
eq(bankaccounts.tenant, req.user.tenant_id),
|
||||||
|
eq(bankaccounts.bankId, CASHBOOK_BANK_ID)
|
||||||
|
))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated[0]) return reply.code(404).send({ error: "Cashbook not found" })
|
||||||
|
return reply.send(updated[0])
|
||||||
|
} catch (err) {
|
||||||
|
server.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to archive cashbook" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/banking/cashbooks/:id/bookings", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const cashbookId = Number(id)
|
||||||
|
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
|
||||||
|
|
||||||
|
const rows = await server.db.select({
|
||||||
|
statement: bankstatements,
|
||||||
|
allocation: statementallocations,
|
||||||
|
account: accounts,
|
||||||
|
customer: customers,
|
||||||
|
vendor: vendors,
|
||||||
|
ownaccount: ownaccounts,
|
||||||
|
incominginvoice: ManualInvoices,
|
||||||
|
incominginvoiceVendor: ManualInvoiceVendors,
|
||||||
|
})
|
||||||
|
.from(bankstatements)
|
||||||
|
.leftJoin(statementallocations, eq(statementallocations.bankstatement, bankstatements.id))
|
||||||
|
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
|
||||||
|
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||||
|
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||||
|
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||||
|
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
|
||||||
|
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
|
||||||
|
.where(and(
|
||||||
|
eq(bankstatements.tenant, req.user.tenant_id),
|
||||||
|
eq(bankstatements.account, cashbookId),
|
||||||
|
eq(bankstatements.archived, false)
|
||||||
|
))
|
||||||
|
.orderBy(desc(bankstatements.date), desc(bankstatements.createdAt))
|
||||||
|
|
||||||
|
return reply.send(rows.map((row) => ({
|
||||||
|
...row.statement,
|
||||||
|
allocation: row.allocation,
|
||||||
|
account: row.account,
|
||||||
|
customer: row.customer,
|
||||||
|
vendor: row.vendor,
|
||||||
|
ownaccount: row.ownaccount,
|
||||||
|
incominginvoice: row.incominginvoice ? {
|
||||||
|
...row.incominginvoice,
|
||||||
|
vendor: row.incominginvoiceVendor,
|
||||||
|
} : null,
|
||||||
|
})))
|
||||||
|
} catch (err) {
|
||||||
|
server.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to load cashbook bookings" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/banking/cashbooks/:id/bookings", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const cashbookId = Number(id)
|
||||||
|
const body = req.body as {
|
||||||
|
date?: string
|
||||||
|
amount?: number
|
||||||
|
direction?: "income" | "expense"
|
||||||
|
counterType?: string
|
||||||
|
counterId?: string | number
|
||||||
|
description?: string
|
||||||
|
datevTaxKey?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." })
|
||||||
|
if (!body.date || !dayjs(body.date).isValid()) return reply.code(400).send({ error: "Bitte ein gültiges Buchungsdatum angeben." })
|
||||||
|
if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." })
|
||||||
|
if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." })
|
||||||
|
|
||||||
|
const cashbook = await server.db.select().from(bankaccounts).where(and(
|
||||||
|
eq(bankaccounts.id, cashbookId),
|
||||||
|
eq(bankaccounts.tenant, req.user.tenant_id),
|
||||||
|
eq(bankaccounts.bankId, CASHBOOK_BANK_ID),
|
||||||
|
eq(bankaccounts.archived, false)
|
||||||
|
)).limit(1)
|
||||||
|
|
||||||
|
if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." })
|
||||||
|
|
||||||
|
const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId)
|
||||||
|
if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." })
|
||||||
|
|
||||||
|
const signedAmount = body.direction === "income"
|
||||||
|
? Math.abs(Number(body.amount))
|
||||||
|
: -Math.abs(Number(body.amount))
|
||||||
|
const description = String(body.description || "").trim() || (body.direction === "income" ? "Bareinnahme" : "Barausgabe")
|
||||||
|
|
||||||
|
const created = await server.db.transaction(async (tx) => {
|
||||||
|
const insertedStatements = await tx.insert(bankstatements).values({
|
||||||
|
account: cashbookId,
|
||||||
|
date: dayjs(body.date).format("YYYY-MM-DD"),
|
||||||
|
amount: signedAmount,
|
||||||
|
tenant: req.user.tenant_id,
|
||||||
|
text: description,
|
||||||
|
currency: "EUR",
|
||||||
|
credName: body.direction === "income" ? cashbook[0].name : description,
|
||||||
|
debName: body.direction === "expense" ? cashbook[0].name : description,
|
||||||
|
updatedBy: req.user.user_id,
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
const statement = insertedStatements[0]
|
||||||
|
const insertedAllocations = await tx.insert(statementallocations).values({
|
||||||
|
bankstatement: statement.id,
|
||||||
|
amount: signedAmount,
|
||||||
|
tenant: req.user.tenant_id,
|
||||||
|
description,
|
||||||
|
datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null,
|
||||||
|
...counterPayload,
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
return {
|
||||||
|
statement,
|
||||||
|
allocation: insertedAllocations[0],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
entity: "bankstatements",
|
||||||
|
entityId: Number(created.statement.id),
|
||||||
|
action: "created",
|
||||||
|
created_by: req.user.user_id,
|
||||||
|
tenant_id: req.user.tenant_id,
|
||||||
|
oldVal: null,
|
||||||
|
newVal: created,
|
||||||
|
text: "Kassenbuchung erstellt",
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send(created)
|
||||||
|
} catch (err) {
|
||||||
|
server.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to create cashbook booking" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.delete("/banking/cashbook-bookings/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const statementId = Number(id)
|
||||||
|
if (!Number.isFinite(statementId)) return reply.code(400).send({ error: "Ungültige Buchung." })
|
||||||
|
|
||||||
|
const records = await server.db.select({
|
||||||
|
statement: bankstatements,
|
||||||
|
cashbook: bankaccounts,
|
||||||
|
})
|
||||||
|
.from(bankstatements)
|
||||||
|
.innerJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
|
||||||
|
.where(and(
|
||||||
|
eq(bankstatements.id, statementId),
|
||||||
|
eq(bankstatements.tenant, req.user.tenant_id),
|
||||||
|
eq(bankaccounts.bankId, CASHBOOK_BANK_ID)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!records[0]) return reply.code(404).send({ error: "Kassenbuchung nicht gefunden." })
|
||||||
|
|
||||||
|
await server.db.transaction(async (tx) => {
|
||||||
|
await tx.delete(statementallocations).where(eq(statementallocations.bankstatement, statementId))
|
||||||
|
await tx.delete(bankstatements).where(eq(bankstatements.id, statementId))
|
||||||
|
})
|
||||||
|
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
entity: "bankstatements",
|
||||||
|
entityId: statementId,
|
||||||
|
action: "deleted",
|
||||||
|
created_by: req.user.user_id,
|
||||||
|
tenant_id: req.user.tenant_id,
|
||||||
|
oldVal: records[0].statement,
|
||||||
|
newVal: null,
|
||||||
|
text: "Kassenbuchung gelöscht",
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
server.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to delete cashbook booking" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const normalizeIban = (value?: string | null) =>
|
const normalizeIban = (value?: string | null) =>
|
||||||
String(value || "").replace(/\s+/g, "").toUpperCase()
|
String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||||
|
|
||||||
@@ -677,6 +1025,64 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 📒 List Manual Statement Allocations
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
server.get("/banking/manual-bookings", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const rows = await server.db.select({
|
||||||
|
allocation: statementallocations,
|
||||||
|
account: accounts,
|
||||||
|
customer: customers,
|
||||||
|
vendor: vendors,
|
||||||
|
ownaccount: ownaccounts,
|
||||||
|
contraAccount: ContraAccounts,
|
||||||
|
contraCustomer: ContraCustomers,
|
||||||
|
contraVendor: ContraVendors,
|
||||||
|
contraOwnaccount: ContraOwnaccounts,
|
||||||
|
incominginvoice: ManualInvoices,
|
||||||
|
incominginvoiceVendor: ManualInvoiceVendors,
|
||||||
|
})
|
||||||
|
.from(statementallocations)
|
||||||
|
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
|
||||||
|
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||||
|
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||||
|
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||||
|
.leftJoin(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id))
|
||||||
|
.leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id))
|
||||||
|
.leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id))
|
||||||
|
.leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id))
|
||||||
|
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
|
||||||
|
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
|
||||||
|
.where(and(
|
||||||
|
eq(statementallocations.tenant, req.user.tenant_id),
|
||||||
|
eq(statementallocations.archived, false),
|
||||||
|
isNull(statementallocations.bankstatement)
|
||||||
|
))
|
||||||
|
|
||||||
|
return reply.send(rows.map((row) => ({
|
||||||
|
...row.allocation,
|
||||||
|
account: row.account,
|
||||||
|
customer: row.customer,
|
||||||
|
vendor: row.vendor,
|
||||||
|
ownaccount: row.ownaccount,
|
||||||
|
contraAccount: row.contraAccount,
|
||||||
|
contraCustomer: row.contraCustomer,
|
||||||
|
contraVendor: row.contraVendor,
|
||||||
|
contraOwnaccount: row.contraOwnaccount,
|
||||||
|
incominginvoice: row.incominginvoice ? {
|
||||||
|
...row.incominginvoice,
|
||||||
|
vendor: row.incominginvoiceVendor,
|
||||||
|
} : null,
|
||||||
|
})))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to load manual bookings" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 💰 Create Statement Allocation
|
// 💰 Create Statement Allocation
|
||||||
@@ -686,9 +1092,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
const { data: payload } = req.body as { data: any }
|
const { data: payload } = req.body as { data: any }
|
||||||
|
const prepared = prepareStatementAllocationPayload(payload)
|
||||||
|
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||||
|
|
||||||
const inserted = await server.db.insert(statementallocations).values({
|
const inserted = await server.db.insert(statementallocations).values({
|
||||||
...payload,
|
...prepared.data,
|
||||||
tenant: req.user.tenant_id
|
tenant: req.user.tenant_id
|
||||||
}).returning()
|
}).returning()
|
||||||
|
|
||||||
@@ -720,16 +1128,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await insertHistoryItem(server, {
|
if (createdRecord.bankstatement) {
|
||||||
entity: "bankstatements",
|
await insertHistoryItem(server, {
|
||||||
entityId: Number(createdRecord.bankstatement),
|
entity: "bankstatements",
|
||||||
action: "created",
|
entityId: Number(createdRecord.bankstatement),
|
||||||
created_by: req.user.user_id,
|
action: "created",
|
||||||
tenant_id: req.user.tenant_id,
|
created_by: req.user.user_id,
|
||||||
oldVal: null,
|
tenant_id: req.user.tenant_id,
|
||||||
newVal: createdRecord,
|
oldVal: null,
|
||||||
text: "Buchung erstellt",
|
newVal: createdRecord,
|
||||||
})
|
text: "Buchung erstellt",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return reply.send(createdRecord)
|
return reply.send(createdRecord)
|
||||||
|
|
||||||
@@ -763,16 +1173,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
.delete(statementallocations)
|
.delete(statementallocations)
|
||||||
.where(eq(statementallocations.id, id))
|
.where(eq(statementallocations.id, id))
|
||||||
|
|
||||||
await insertHistoryItem(server, {
|
if (old.bankstatement) {
|
||||||
entity: "bankstatements",
|
await insertHistoryItem(server, {
|
||||||
entityId: Number(old.bankstatement),
|
entity: "bankstatements",
|
||||||
action: "deleted",
|
entityId: Number(old.bankstatement),
|
||||||
created_by: req.user.user_id,
|
action: "deleted",
|
||||||
tenant_id: req.user.tenant_id,
|
created_by: req.user.user_id,
|
||||||
oldVal: old,
|
tenant_id: req.user.tenant_id,
|
||||||
newVal: null,
|
oldVal: old,
|
||||||
text: "Buchung gelöscht",
|
newVal: null,
|
||||||
})
|
text: "Buchung gelöscht",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return reply.send({ success: true })
|
return reply.send({ success: true })
|
||||||
|
|
||||||
|
|||||||
323
backend/src/routes/communication.ts
Normal file
323
backend/src/routes/communication.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, eq, ne } from "drizzle-orm"
|
||||||
|
import { authTenantUsers, authUsers } from "../../db/schema"
|
||||||
|
import { matrixService } from "../modules/matrix.service"
|
||||||
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
|
|
||||||
|
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||||
|
const rows = await server.db
|
||||||
|
.select({ email: authUsers.email })
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.id, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function communicationRoutes(server: FastifyInstance) {
|
||||||
|
const matrix = matrixService(server)
|
||||||
|
const notifications = new NotificationService(server, getUserDirectory)
|
||||||
|
const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply
|
||||||
|
.code(err.statusCode || 500)
|
||||||
|
.send({ error: err.message || fallbackMessage })
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomOptionsFromRequest = (req: any) => {
|
||||||
|
const params = req.params as { roomKey?: string }
|
||||||
|
const body = (req.body || {}) as {
|
||||||
|
key?: string
|
||||||
|
name?: string
|
||||||
|
topic?: string
|
||||||
|
type?: string
|
||||||
|
entityType?: string | null
|
||||||
|
entityId?: number | null
|
||||||
|
entityUuid?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: params.roomKey || body.key,
|
||||||
|
name: body.name,
|
||||||
|
topic: body.topic,
|
||||||
|
type: body.type,
|
||||||
|
entityType: body.entityType,
|
||||||
|
entityId: body.entityId,
|
||||||
|
entityUuid: body.entityUuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const callModeFromRequest = (req: any): "audio" | "video" => {
|
||||||
|
const body = (req.body || {}) as { mode?: string }
|
||||||
|
return body.mode === "audio" ? "audio" : "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyTenantUsersAboutCall = async (req: any, room: { key?: string; name?: string }, mode: "audio" | "video") => {
|
||||||
|
if (!req.user.tenant_id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recipientRows = await server.db
|
||||||
|
.select({ userId: authTenantUsers.user_id })
|
||||||
|
.from(authTenantUsers)
|
||||||
|
.where(and(
|
||||||
|
eq(authTenantUsers.tenant_id, req.user.tenant_id),
|
||||||
|
ne(authTenantUsers.user_id, req.user.user_id)
|
||||||
|
))
|
||||||
|
|
||||||
|
const userIds = recipientRows.map((row) => row.userId)
|
||||||
|
if (!userIds.length) return
|
||||||
|
|
||||||
|
await notifications.trigger({
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
userIds,
|
||||||
|
eventType: "communication.call.started",
|
||||||
|
title: mode === "audio" ? "Audioanruf gestartet" : "Videokonferenz gestartet",
|
||||||
|
message: `${room.name || room.key || "Ein Chatraum"} hat eine laufende Besprechung.`,
|
||||||
|
payload: {
|
||||||
|
link: "/communication/chat",
|
||||||
|
roomKey: room.key,
|
||||||
|
roomName: room.name,
|
||||||
|
mode,
|
||||||
|
},
|
||||||
|
channels: ["inapp", "push"],
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, "Call-Benachrichtigung konnte nicht ausgelöst werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.get("/communication/matrix/status", async () => {
|
||||||
|
return matrix.getStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/me", async (req) => {
|
||||||
|
const userId = req.user.user_id
|
||||||
|
|
||||||
|
return {
|
||||||
|
matrixUserId: await matrix.matrixUserIdForUser(userId, req.user.tenant_id),
|
||||||
|
displayName: await matrix.getCurrentUserDisplayName(userId, req.user.tenant_id),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/me/provision", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.provisionCurrentUser(req.user.user_id, req.user.tenant_id)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix provisioning failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/tenant-space", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.getTenantSpaceStatus(req.user.tenant_id)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix tenant space status failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/tenant-space/provision", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.provisionCurrentTenantSpace(req.user.user_id, req.user.tenant_id)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix tenant space provisioning failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/rooms", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.listTenantRooms(req.user.tenant_id)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix rooms failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.provisionTenantRoom(
|
||||||
|
req.user.user_id,
|
||||||
|
req.user.tenant_id,
|
||||||
|
roomOptionsFromRequest(req)
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/rooms/general", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix room status failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/general/provision", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
|
||||||
|
key: "allgemein",
|
||||||
|
name: "Allgemeiner Chat",
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/rooms/general/messages", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.getGeneralRoomMessages(req.user.user_id, req.user.tenant_id)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix messages failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/rooms/general/members", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.getGeneralRoomMembers(req.user.user_id, req.user.tenant_id)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix members failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/general/session", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, {
|
||||||
|
key: "allgemein",
|
||||||
|
name: "Allgemeiner Chat",
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix session failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/general/call-session", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const room = {
|
||||||
|
key: "allgemein",
|
||||||
|
name: "Allgemeiner Chat",
|
||||||
|
}
|
||||||
|
const session = await matrix.createLiveKitRoomSession(req.user.user_id, req.user.tenant_id, room)
|
||||||
|
await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req))
|
||||||
|
return session
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix call session failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/general/members/sync", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.syncTenantRoomMembers(req.user.user_id, req.user.tenant_id, {
|
||||||
|
key: "allgemein",
|
||||||
|
name: "Allgemeiner Chat",
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix member sync failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as { text?: string }
|
||||||
|
return await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix message send failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const params = req.params as { roomKey: string }
|
||||||
|
return await matrix.getTenantRoomStatus(req.user.tenant_id, params.roomKey)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix room status failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/:roomKey/provision", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.provisionTenantRoom(
|
||||||
|
req.user.user_id,
|
||||||
|
req.user.tenant_id,
|
||||||
|
roomOptionsFromRequest(req)
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.getTenantRoomMessages(
|
||||||
|
req.user.user_id,
|
||||||
|
req.user.tenant_id,
|
||||||
|
roomOptionsFromRequest(req)
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix messages failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.getTenantRoomMembers(
|
||||||
|
req.user.user_id,
|
||||||
|
req.user.tenant_id,
|
||||||
|
roomOptionsFromRequest(req)
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix members failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.createElementRoomSession(
|
||||||
|
req.user.user_id,
|
||||||
|
req.user.tenant_id,
|
||||||
|
roomOptionsFromRequest(req)
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix session failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/:roomKey/call-session", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const room = roomOptionsFromRequest(req)
|
||||||
|
const session = await matrix.createLiveKitRoomSession(
|
||||||
|
req.user.user_id,
|
||||||
|
req.user.tenant_id,
|
||||||
|
room
|
||||||
|
)
|
||||||
|
await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req))
|
||||||
|
return session
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix call session failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/:roomKey/members/sync", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await matrix.syncTenantRoomMembers(
|
||||||
|
req.user.user_id,
|
||||||
|
req.user.tenant_id,
|
||||||
|
roomOptionsFromRequest(req)
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix member sync failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as { text?: string }
|
||||||
|
return await matrix.sendTenantRoomMessage(
|
||||||
|
req.user.user_id,
|
||||||
|
req.user.tenant_id,
|
||||||
|
roomOptionsFromRequest(req),
|
||||||
|
body.text || ""
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix message send failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets"
|
|||||||
import { saveFile } from "../utils/files"
|
import { saveFile } from "../utils/files"
|
||||||
|
|
||||||
import { eq, inArray } from "drizzle-orm"
|
import { eq, inArray } from "drizzle-orm"
|
||||||
|
import { and } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
|
authProfiles,
|
||||||
files,
|
files,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
customers
|
customers
|
||||||
@@ -18,6 +20,55 @@ import {
|
|||||||
|
|
||||||
|
|
||||||
export default async function fileRoutes(server: FastifyInstance) {
|
export default async function fileRoutes(server: FastifyInstance) {
|
||||||
|
const getPortalCustomerId = async (req: any) => {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
const userId = req.user?.user_id
|
||||||
|
|
||||||
|
if (!tenantId || !userId) return null
|
||||||
|
|
||||||
|
const [profile] = await server.db
|
||||||
|
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
|
eq(authProfiles.user_id, userId)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return profile?.customer_for_portal || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSingleFileForRequest = async (req: any, id: string) => {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
if (!tenantId) return null
|
||||||
|
|
||||||
|
const portalCustomerId = await getPortalCustomerId(req)
|
||||||
|
|
||||||
|
if (!portalCustomerId) {
|
||||||
|
const rows = await server.db
|
||||||
|
.select()
|
||||||
|
.from(files)
|
||||||
|
.where(and(eq(files.id, id), eq(files.tenant, tenantId)))
|
||||||
|
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await server.db
|
||||||
|
.select({
|
||||||
|
file: files,
|
||||||
|
})
|
||||||
|
.from(files)
|
||||||
|
.leftJoin(createddocuments, eq(files.createddocument, createddocuments.id))
|
||||||
|
.where(and(
|
||||||
|
eq(files.id, id),
|
||||||
|
eq(files.tenant, tenantId),
|
||||||
|
eq(createddocuments.customer, portalCustomerId),
|
||||||
|
eq(createddocuments.availableInPortal, true)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return rows[0]?.file || null
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// MULTIPART INIT
|
// MULTIPART INIT
|
||||||
@@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
// 🔹 EINZELNE DATEI
|
// 🔹 EINZELNE DATEI
|
||||||
if (id) {
|
if (id) {
|
||||||
const rows = await server.db
|
const file = await loadSingleFileForRequest(req, id)
|
||||||
.select()
|
|
||||||
.from(files)
|
|
||||||
.where(eq(files.id, id))
|
|
||||||
|
|
||||||
const file = rows[0]
|
|
||||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||||
|
|
||||||
return file
|
return file
|
||||||
@@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
|||||||
// 1️⃣ SINGLE DOWNLOAD
|
// 1️⃣ SINGLE DOWNLOAD
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
if (id) {
|
if (id) {
|
||||||
const rows = await server.db
|
const file = await loadSingleFileForRequest(req, id)
|
||||||
.select()
|
|
||||||
.from(files)
|
|
||||||
.where(eq(files.id, id))
|
|
||||||
|
|
||||||
const file = rows[0]
|
|
||||||
if (!file) return reply.code(404).send({ error: "File not found" })
|
if (!file) return reply.code(404).send({ error: "File not found" })
|
||||||
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
@@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
|||||||
// SINGLE FILE PRESIGNED URL
|
// SINGLE FILE PRESIGNED URL
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
if (id) {
|
if (id) {
|
||||||
const rows = await server.db
|
const file = await loadSingleFileForRequest(req, id)
|
||||||
.select()
|
|
||||||
.from(files)
|
|
||||||
.where(eq(files.id, id))
|
|
||||||
|
|
||||||
const file = rows[0]
|
|
||||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||||
|
|
||||||
const url = await getSignedUrl(
|
const url = await getSignedUrl(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
|||||||
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
|
|||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||||
|
import { generateLiquidityForecast } from "../utils/liquidityForecast";
|
||||||
dayjs.extend(customParseFormat)
|
dayjs.extend(customParseFormat)
|
||||||
dayjs.extend(isoWeek)
|
dayjs.extend(isoWeek)
|
||||||
dayjs.extend(isBetween)
|
dayjs.extend(isBetween)
|
||||||
@@ -57,6 +58,42 @@ function resolveGitRoot() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDeploymentChangelogFallback() {
|
||||||
|
const backendPackagePath = path.resolve(process.cwd(), "package.json")
|
||||||
|
let version = "unbekannt"
|
||||||
|
|
||||||
|
if (existsSync(backendPackagePath)) {
|
||||||
|
try {
|
||||||
|
const packageJson = JSON.parse(readFileSync(backendPackagePath, "utf-8"))
|
||||||
|
version = packageJson?.version || version
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not read backend package.json for changelog fallback", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitHash =
|
||||||
|
process.env.RAILWAY_GIT_COMMIT_SHA ||
|
||||||
|
process.env.VERCEL_GIT_COMMIT_SHA ||
|
||||||
|
process.env.GITHUB_SHA ||
|
||||||
|
process.env.COMMIT_SHA ||
|
||||||
|
process.env.SOURCE_COMMIT ||
|
||||||
|
null
|
||||||
|
|
||||||
|
const committedAt =
|
||||||
|
process.env.BUILD_DATE ||
|
||||||
|
process.env.RENDER_GIT_COMMIT_DATE ||
|
||||||
|
process.env.VERCEL_GIT_COMMIT_DATE ||
|
||||||
|
new Date().toISOString()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
hash: commitHash || `version-${version}`,
|
||||||
|
shortHash: commitHash ? commitHash.slice(0, 7) : `v${version}`,
|
||||||
|
subject: `Bereitgestellte Version ${version}`,
|
||||||
|
authorName: "Deployment",
|
||||||
|
committedAt
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
export default async function functionRoutes(server: FastifyInstance) {
|
export default async function functionRoutes(server: FastifyInstance) {
|
||||||
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
@@ -201,7 +238,11 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
const gitRoot = resolveGitRoot()
|
const gitRoot = resolveGitRoot()
|
||||||
|
|
||||||
if (!gitRoot) {
|
if (!gitRoot) {
|
||||||
return reply.code(500).send({ error: 'Git repository not found' })
|
return reply.send({
|
||||||
|
repositoryRoot: null,
|
||||||
|
source: 'deployment',
|
||||||
|
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -232,11 +273,16 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
repositoryRoot: gitRoot,
|
repositoryRoot: gitRoot,
|
||||||
|
source: 'git',
|
||||||
entries
|
entries
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.log.error(err)
|
req.log.error(err)
|
||||||
return reply.code(500).send({ error: 'Failed to load changelog' })
|
return reply.send({
|
||||||
|
repositoryRoot: gitRoot,
|
||||||
|
source: 'deployment',
|
||||||
|
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,6 +307,21 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
||||||
|
const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
|
||||||
|
const ignoredKeys = String(ignoredRecurringKeys || "")
|
||||||
|
.split(",")
|
||||||
|
.map((key) => key.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.post('/functions/services/backfillfiletext', async (req, reply) => {
|
server.post('/functions/services/backfillfiletext', async (req, reply) => {
|
||||||
const tenantId = req.user.tenant_id
|
const tenantId = req.user.tenant_id
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
|
|||||||
|
|
||||||
const columnMap: Record<string, any> = {
|
const columnMap: Record<string, any> = {
|
||||||
customers: historyitems.customer,
|
customers: historyitems.customer,
|
||||||
|
contracts: historyitems.contract,
|
||||||
members: historyitems.customer,
|
members: historyitems.customer,
|
||||||
vendors: historyitems.vendor,
|
vendors: historyitems.vendor,
|
||||||
projects: historyitems.project,
|
projects: historyitems.project,
|
||||||
@@ -26,10 +27,12 @@ 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> = {
|
||||||
customers: "customer",
|
customers: "customer",
|
||||||
|
contracts: "contract",
|
||||||
members: "customer",
|
members: "customer",
|
||||||
vendors: "vendor",
|
vendors: "vendor",
|
||||||
projects: "project",
|
projects: "project",
|
||||||
@@ -51,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) => {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
|
|
||||||
import {and, eq, inArray} from "drizzle-orm"
|
import {and, eq, inArray} from "drizzle-orm"
|
||||||
|
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||||
|
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||||
@@ -53,7 +55,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
.where(inArray(authUsers.id, userIds))
|
.where(inArray(authUsers.id, userIds))
|
||||||
|
|
||||||
// 3) auth_profiles pro Tenant laden
|
// 3) auth_profiles pro Tenant laden
|
||||||
const profiles = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(
|
.where(
|
||||||
@@ -61,6 +63,8 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
eq(authProfiles.tenant_id, tenantId),
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
inArray(authProfiles.user_id, userIds)
|
inArray(authProfiles.user_id, userIds)
|
||||||
))
|
))
|
||||||
|
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||||
|
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||||
|
|
||||||
const combined = users.map(u => {
|
const combined = users.map(u => {
|
||||||
const profile = profiles.find(p => p.user_id === u.id)
|
const profile = profiles.find(p => p.user_id === u.id)
|
||||||
@@ -91,12 +95,13 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
const tenantId = req.params.id
|
const tenantId = req.params.id
|
||||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
const data = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(eq(authProfiles.tenant_id, tenantId))
|
.where(eq(authProfiles.tenant_id, tenantId))
|
||||||
|
|
||||||
return data
|
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||||
|
return await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("/tenant/profiles ERROR:", err)
|
console.error("/tenant/profiles ERROR:", err)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
desc
|
desc
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
@@ -102,7 +102,7 @@ export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
|||||||
const combinedEvents = [
|
const combinedEvents = [
|
||||||
...factualEvents,
|
...factualEvents,
|
||||||
...relatedAdminEvents,
|
...relatedAdminEvents,
|
||||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
].sort(compareTimeEvents);
|
||||||
|
|
||||||
// SCHRITT 5: Spans ableiten
|
// SCHRITT 5: Spans ableiten
|
||||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
|
|||||||
299
backend/src/routes/mcp.ts
Normal file
299
backend/src/routes/mcp.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, desc, eq } from "drizzle-orm"
|
||||||
|
import { createHash, randomBytes } from "node:crypto"
|
||||||
|
import { m2mApiKeys } from "../../db/schema"
|
||||||
|
import { assertToolPermission, createMcpContext } from "../mcp/authz"
|
||||||
|
import { mcpToolMap, mcpTools } from "../mcp/registry"
|
||||||
|
import { asToolError, asToolResult } from "../mcp/result"
|
||||||
|
import { JsonRpcRequest } from "../mcp/types"
|
||||||
|
|
||||||
|
const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||||
|
"2025-11-25",
|
||||||
|
"2025-06-18",
|
||||||
|
"2025-03-26",
|
||||||
|
"2024-11-05",
|
||||||
|
]
|
||||||
|
|
||||||
|
function jsonRpcResult(id: JsonRpcRequest["id"], result: unknown) {
|
||||||
|
return {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id,
|
||||||
|
result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: string) {
|
||||||
|
return {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: id ?? null,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProtocolVersion(clientVersion?: string) {
|
||||||
|
if (clientVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) {
|
||||||
|
return clientVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
return SUPPORTED_PROTOCOL_VERSIONS[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashApiKey = (apiKey: string) =>
|
||||||
|
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||||||
|
|
||||||
|
const createMcpToken = () => `fedeo_mcp_${randomBytes(32).toString("base64url")}`
|
||||||
|
|
||||||
|
const requireMcpTokenManagementPermission = (req: any) => {
|
||||||
|
if (req.mcpTokenAuth) {
|
||||||
|
throw Object.assign(new Error("MCP Tokens können nur mit einer Benutzersitzung verwaltet werden"), { statusCode: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user?.is_admin) return
|
||||||
|
if (typeof req.hasPermission === "function" && req.hasPermission("mcp.tokens.write")) return
|
||||||
|
|
||||||
|
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function mcpRoutes(server: FastifyInstance) {
|
||||||
|
server.get("/mcp/tokens", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
requireMcpTokenManagementPermission(req)
|
||||||
|
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await server.db
|
||||||
|
.select({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
createdAt: m2mApiKeys.createdAt,
|
||||||
|
updatedAt: m2mApiKeys.updatedAt,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
keyPrefix: m2mApiKeys.keyPrefix,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
lastUsedAt: m2mApiKeys.lastUsedAt,
|
||||||
|
expiresAt: m2mApiKeys.expiresAt,
|
||||||
|
userId: m2mApiKeys.userId,
|
||||||
|
createdBy: m2mApiKeys.createdBy,
|
||||||
|
})
|
||||||
|
.from(m2mApiKeys)
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||||
|
))
|
||||||
|
.orderBy(desc(m2mApiKeys.createdAt))
|
||||||
|
|
||||||
|
return { rows: rows.filter((row) => row.keyPrefix.startsWith("fedeo_mcp_")) }
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/mcp/tokens", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
requireMcpTokenManagementPermission(req)
|
||||||
|
|
||||||
|
if (!req.user?.tenant_id || !req.user?.user_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.body as {
|
||||||
|
name?: string
|
||||||
|
expiresAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createMcpToken()
|
||||||
|
const expiresAt = body?.expiresAt ? new Date(body.expiresAt) : null
|
||||||
|
|
||||||
|
if (expiresAt && Number.isNaN(expiresAt.getTime())) {
|
||||||
|
return reply.code(400).send({ error: "expiresAt must be a valid date" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await server.db
|
||||||
|
.insert(m2mApiKeys)
|
||||||
|
.values({
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
userId: req.user.user_id,
|
||||||
|
createdBy: req.user.user_id,
|
||||||
|
name: body?.name?.trim() || "FEDEO MCP Token",
|
||||||
|
keyPrefix: token.slice(0, 20),
|
||||||
|
keyHash: hashApiKey(token),
|
||||||
|
active: true,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
createdAt: m2mApiKeys.createdAt,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
keyPrefix: m2mApiKeys.keyPrefix,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
expiresAt: m2mApiKeys.expiresAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
note: "Token wird nur einmal angezeigt. Bitte direkt in Codex oder einem Secret Store hinterlegen.",
|
||||||
|
record: created,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.delete("/mcp/tokens/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
requireMcpTokenManagementPermission(req)
|
||||||
|
|
||||||
|
if (!req.user?.tenant_id || !req.user?.user_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
|
||||||
|
const [existing] = await server.db
|
||||||
|
.select({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
keyPrefix: m2mApiKeys.keyPrefix,
|
||||||
|
})
|
||||||
|
.from(m2mApiKeys)
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.id, id),
|
||||||
|
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing || !existing.keyPrefix.startsWith("fedeo_mcp_")) {
|
||||||
|
return reply.code(404).send({ error: "MCP token not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await server.db
|
||||||
|
.update(m2mApiKeys)
|
||||||
|
.set({
|
||||||
|
active: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.id, id),
|
||||||
|
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||||
|
))
|
||||||
|
.returning({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
keyPrefix: m2mApiKeys.keyPrefix,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { token: updated }
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/mcp", async (req, reply) => {
|
||||||
|
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
||||||
|
const requests = Array.isArray(body) ? body : [body]
|
||||||
|
const responses = []
|
||||||
|
|
||||||
|
for (const request of requests) {
|
||||||
|
const id = request?.id
|
||||||
|
|
||||||
|
if (!request || request.jsonrpc !== "2.0" || !request.method) {
|
||||||
|
responses.push(jsonRpcError(id, -32600, "Invalid JSON-RPC request"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "notifications/initialized") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "initialize") {
|
||||||
|
const clientVersion = request.params?.protocolVersion
|
||||||
|
|
||||||
|
responses.push(jsonRpcResult(id, {
|
||||||
|
protocolVersion: selectProtocolVersion(clientVersion),
|
||||||
|
capabilities: {
|
||||||
|
tools: {
|
||||||
|
listChanged: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serverInfo: {
|
||||||
|
name: "fedeo-mcp",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.",
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "ping") {
|
||||||
|
responses.push(jsonRpcResult(id, {}))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "tools/list") {
|
||||||
|
responses.push(jsonRpcResult(id, {
|
||||||
|
tools: mcpTools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
title: tool.title,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "tools/call") {
|
||||||
|
const toolName = request.params?.name
|
||||||
|
const tool = typeof toolName === "string" ? mcpToolMap.get(toolName) : null
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
responses.push(jsonRpcError(id, -32602, `Unknown tool: ${toolName}`))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await createMcpContext(server, req)
|
||||||
|
assertToolPermission(context, tool)
|
||||||
|
|
||||||
|
const result = await tool.handler(context, request.params?.arguments || {})
|
||||||
|
responses.push(jsonRpcResult(id, asToolResult(result)))
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode
|
||||||
|
|
||||||
|
if (statusCode === 401 || statusCode === 403) {
|
||||||
|
responses.push(jsonRpcError(id, statusCode === 401 ? -32001 : -32003, error instanceof Error ? error.message : "Forbidden"))
|
||||||
|
} else {
|
||||||
|
responses.push(jsonRpcResult(id, asToolError(error)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.push(jsonRpcError(id, -32601, `Method not found: ${request.method}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responses.length === 0) {
|
||||||
|
return reply.code(204).send()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(body) ? responses : responses[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/mcp", async (_req, reply) => {
|
||||||
|
return reply.send({
|
||||||
|
name: "fedeo-mcp",
|
||||||
|
transport: "http-json-rpc",
|
||||||
|
endpoint: "/api/mcp",
|
||||||
|
tools: mcpTools.map((tool) => tool.name),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,31 +1,92 @@
|
|||||||
// routes/notifications.routes.ts
|
import { FastifyInstance } from "fastify"
|
||||||
import { FastifyInstance } from 'fastify';
|
import { eq } from "drizzle-orm"
|
||||||
import { NotificationService, UserDirectory } from '../modules/notification.service';
|
import { authUsers } from "../../db/schema"
|
||||||
import { eq } from "drizzle-orm";
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
import { authUsers } from "../../db/schema";
|
|
||||||
|
|
||||||
// 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/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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
230
backend/src/routes/portal/contracts.ts
Normal file
230
backend/src/routes/portal/contracts.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { authProfiles, contracts, contracttypes } from "../../../db/schema"
|
||||||
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
|
|
||||||
|
async function getPortalCustomerId(server: FastifyInstance, req: any) {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
const userId = req.user?.user_id
|
||||||
|
|
||||||
|
if (!tenantId || !userId) return null
|
||||||
|
|
||||||
|
const [profile] = await server.db
|
||||||
|
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
|
eq(authProfiles.user_id, userId)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return profile?.customer_for_portal || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPortalContract(server: FastifyInstance, req: any, contractId: number) {
|
||||||
|
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||||
|
if (!portalCustomerId) return null
|
||||||
|
|
||||||
|
const [contract] = await server.db
|
||||||
|
.select({
|
||||||
|
id: contracts.id,
|
||||||
|
name: contracts.name,
|
||||||
|
tenant: contracts.tenant,
|
||||||
|
customer: contracts.customer,
|
||||||
|
contracttype: contracts.contracttype,
|
||||||
|
allowedContracttypes: contracts.allowedContracttypes,
|
||||||
|
archived: contracts.archived,
|
||||||
|
})
|
||||||
|
.from(contracts)
|
||||||
|
.where(and(
|
||||||
|
eq(contracts.id, contractId),
|
||||||
|
eq(contracts.tenant, req.user?.tenant_id),
|
||||||
|
eq(contracts.customer, portalCustomerId),
|
||||||
|
eq(contracts.archived, false)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return contract || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessage(message: unknown) {
|
||||||
|
if (typeof message !== "string") return ""
|
||||||
|
return message.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(text: string, message: string) {
|
||||||
|
return message ? `${text} Nachricht: ${message}` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForHistory(value: string) {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function portalContractRoutes(server: FastifyInstance) {
|
||||||
|
server.post<{
|
||||||
|
Params: { id: string }
|
||||||
|
Body: { contracttype?: number | string; message?: string }
|
||||||
|
}>("/portal/contracts/:id/change-request", {
|
||||||
|
schema: {
|
||||||
|
tags: ["Portal"],
|
||||||
|
summary: "Request contract type change from customer portal",
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["contracttype"],
|
||||||
|
properties: {
|
||||||
|
contracttype: { anyOf: [{ type: "number" }, { type: "string" }] },
|
||||||
|
message: { type: "string", nullable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const contractId = Number(req.params.id)
|
||||||
|
const requestedContracttypeId = Number(req.body.contracttype)
|
||||||
|
|
||||||
|
if (!Number.isInteger(contractId) || !Number.isInteger(requestedContracttypeId)) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Anfrage" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await getPortalContract(server, req, contractId)
|
||||||
|
if (!contract) {
|
||||||
|
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [requestedContracttype] = await server.db
|
||||||
|
.select({
|
||||||
|
id: contracttypes.id,
|
||||||
|
name: contracttypes.name,
|
||||||
|
})
|
||||||
|
.from(contracttypes)
|
||||||
|
.where(and(
|
||||||
|
eq(contracttypes.id, requestedContracttypeId),
|
||||||
|
eq(contracttypes.tenant, req.user?.tenant_id),
|
||||||
|
eq(contracttypes.archived, false)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!requestedContracttype) {
|
||||||
|
return reply.code(400).send({ error: "Ungültiger Vertragstyp" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedContracttypes = Array.isArray(contract.allowedContracttypes)
|
||||||
|
? contract.allowedContracttypes.map((id) => Number(id)).filter((id) => Number.isInteger(id))
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!allowedContracttypes.includes(requestedContracttype.id)) {
|
||||||
|
return reply.code(400).send({ error: "Dieser Vertragstyp steht für diesen Vertrag nicht zur Auswahl" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [currentContracttype] = contract.contracttype
|
||||||
|
? await server.db
|
||||||
|
.select({
|
||||||
|
id: contracttypes.id,
|
||||||
|
name: contracttypes.name,
|
||||||
|
})
|
||||||
|
.from(contracttypes)
|
||||||
|
.where(and(
|
||||||
|
eq(contracttypes.id, contract.contracttype),
|
||||||
|
eq(contracttypes.tenant, req.user?.tenant_id)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const message = normalizeMessage(req.body.message)
|
||||||
|
const oldName = currentContracttype?.name || "Ohne Vertragstyp"
|
||||||
|
const newName = requestedContracttype.name
|
||||||
|
const text = appendMessage(
|
||||||
|
`Kundenportal: Änderung des Vertragstyps von "${oldName}" auf "${newName}" angefragt.`,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: req.user?.tenant_id,
|
||||||
|
created_by: req.user?.user_id || null,
|
||||||
|
entity: "contracts",
|
||||||
|
entityId: contract.id,
|
||||||
|
action: "unchanged",
|
||||||
|
oldVal: { contracttype: contract.contracttype, name: oldName },
|
||||||
|
newVal: { contracttype: requestedContracttype.id, name: newName },
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post<{
|
||||||
|
Params: { id: string }
|
||||||
|
Body: { requestedEndDate?: string; message?: string }
|
||||||
|
}>("/portal/contracts/:id/cancellation-request", {
|
||||||
|
schema: {
|
||||||
|
tags: ["Portal"],
|
||||||
|
summary: "Request contract cancellation from customer portal",
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["requestedEndDate"],
|
||||||
|
properties: {
|
||||||
|
requestedEndDate: { type: "string" },
|
||||||
|
message: { type: "string", nullable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const contractId = Number(req.params.id)
|
||||||
|
const requestedEndDate = typeof req.body.requestedEndDate === "string"
|
||||||
|
? req.body.requestedEndDate.trim()
|
||||||
|
: ""
|
||||||
|
|
||||||
|
if (!Number.isInteger(contractId) || !requestedEndDate) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Anfrage" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDate = new Date(requestedEndDate)
|
||||||
|
if (Number.isNaN(parsedDate.getTime())) {
|
||||||
|
return reply.code(400).send({ error: "Ungültiges Kündigungsdatum" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await getPortalContract(server, req, contractId)
|
||||||
|
if (!contract) {
|
||||||
|
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = normalizeMessage(req.body.message)
|
||||||
|
const text = appendMessage(
|
||||||
|
`Kundenportal: Kündigung zum ${formatDateForHistory(requestedEndDate)} angefragt.`,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: req.user?.tenant_id,
|
||||||
|
created_by: req.user?.user_id || null,
|
||||||
|
entity: "contracts",
|
||||||
|
entityId: contract.id,
|
||||||
|
action: "unchanged",
|
||||||
|
newVal: { requestedEndDate },
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,16 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
authProfiles,
|
authProfiles,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
|
import {
|
||||||
|
loadProfileWithBranches,
|
||||||
|
resolveTenantBranchIds,
|
||||||
|
syncProfileBranches,
|
||||||
|
} from "../utils/profileBranches";
|
||||||
|
import {
|
||||||
|
enrichProfilesWithTeams,
|
||||||
|
resolveTenantTeamIds,
|
||||||
|
syncProfileTeams,
|
||||||
|
} from "../utils/profileTeams";
|
||||||
|
|
||||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
@@ -19,22 +29,16 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(400).send({ error: "No tenant selected" });
|
return reply.code(400).send({ error: "No tenant selected" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await server.db
|
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
|
||||||
.select()
|
const [profile] = profileWithBranches
|
||||||
.from(authProfiles)
|
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||||
.where(
|
: [null]
|
||||||
and(
|
|
||||||
eq(authProfiles.id, id),
|
|
||||||
eq(authProfiles.tenant_id, tenantId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!rows.length) {
|
if (!profile) {
|
||||||
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 rows[0];
|
return profile;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("GET /profiles/:id ERROR:", error);
|
console.error("GET /profiles/:id ERROR:", error);
|
||||||
@@ -48,7 +52,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
// ❌ 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"
|
||||||
]
|
]
|
||||||
forbidden.forEach(f => delete cleaned[f])
|
forbidden.forEach(f => delete cleaned[f])
|
||||||
|
|
||||||
@@ -89,8 +94,32 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
// Clean + Normalize
|
// Clean + Normalize
|
||||||
body = sanitizeProfileUpdate(body)
|
body = sanitizeProfileUpdate(body)
|
||||||
|
|
||||||
|
const { primaryBranchId, branchIds } = await resolveTenantBranchIds(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
[
|
||||||
|
...(Array.isArray(body.branch_ids) ? body.branch_ids : []),
|
||||||
|
...(Array.isArray(body.branches) ? body.branches : []),
|
||||||
|
],
|
||||||
|
body.branch_id ?? body.branch?.id ?? null
|
||||||
|
)
|
||||||
|
const teamIds = await resolveTenantTeamIds(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
[
|
||||||
|
...(Array.isArray(body.team_ids) ? body.team_ids : []),
|
||||||
|
...(Array.isArray(body.teams) ? body.teams : []),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
delete body.branch_ids
|
||||||
|
delete body.branches
|
||||||
|
delete body.team_ids
|
||||||
|
delete body.teams
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
...body,
|
...body,
|
||||||
|
branch_id: primaryBranchId,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
updatedBy: userId
|
updatedBy: userId
|
||||||
}
|
}
|
||||||
@@ -110,10 +139,23 @@ 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 updated[0]
|
await syncProfileBranches(server, id, branchIds, userId)
|
||||||
|
await syncProfileTeams(server, id, teamIds, userId)
|
||||||
|
|
||||||
|
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
|
||||||
|
const [profile] = profileWithBranches
|
||||||
|
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||||
|
: [null]
|
||||||
|
return profile || updated[0]
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("PUT /profiles/:id ERROR:", err)
|
console.error("PUT /profiles/:id ERROR:", err)
|
||||||
|
if (err instanceof Error && ["INVALID_BRANCH_SELECTION", "INVALID_PRIMARY_BRANCH"].includes(err.message)) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Niederlassungsauswahl" })
|
||||||
|
}
|
||||||
|
if (err instanceof Error && err.message === "INVALID_TEAM_SELECTION") {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Teamauswahl" })
|
||||||
|
}
|
||||||
return reply.code(500).send({ error: "Internal Server Error" })
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
|
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";
|
||||||
@@ -18,6 +19,12 @@ import { diffObjects } from "../../utils/diff";
|
|||||||
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
|
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
|
||||||
import { decrypt, encrypt } from "../../utils/crypt";
|
import { decrypt, encrypt } from "../../utils/crypt";
|
||||||
|
|
||||||
|
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
|
||||||
|
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
|
||||||
|
const OUTGOING_SEPA_MANDATE_STATUSES = new Set(["Entwurf", "Aktiv", "Widerrufen", "Abgelaufen"])
|
||||||
|
const OUTGOING_SEPA_MANDATE_TYPES = new Set(["CORE", "B2B"])
|
||||||
|
const OUTGOING_SEPA_SEQUENCE_TYPES = new Set(["RCUR", "OOFF", "FRST", "FNAL"])
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
|
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -130,12 +137,92 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
|
|||||||
return whereCond
|
return whereCond
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getPortalCustomerId(server: FastifyInstance, req: any) {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
const userId = req.user?.user_id
|
||||||
|
|
||||||
|
if (!tenantId || !userId) return null
|
||||||
|
|
||||||
|
const [profile] = await server.db
|
||||||
|
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
|
eq(authProfiles.user_id, userId)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return profile?.customer_for_portal || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
|
||||||
|
if (!portalCustomerId) return whereCond
|
||||||
|
|
||||||
|
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === "customers") {
|
||||||
|
return and(whereCond, eq(table.id, portalCustomerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === "contracts") {
|
||||||
|
return and(whereCond, eq(table.customer, portalCustomerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === "createddocuments") {
|
||||||
|
return and(
|
||||||
|
whereCond,
|
||||||
|
eq(table.customer, portalCustomerId),
|
||||||
|
eq(table.availableInPortal, true),
|
||||||
|
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return whereCond
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
|
||||||
|
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: payload.name,
|
||||||
|
firstname: payload.firstname,
|
||||||
|
lastname: payload.lastname,
|
||||||
|
salutation: payload.salutation,
|
||||||
|
title: payload.title,
|
||||||
|
nameAddition: payload.nameAddition,
|
||||||
|
infoData: nextInfoData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getTenantColumn(resource: string, table: any) {
|
function getTenantColumn(resource: string, table: any) {
|
||||||
const config = resourceConfig[resource]
|
const config = resourceConfig[resource]
|
||||||
const tenantKey = config?.tenantKey || "tenant"
|
const tenantKey = config?.tenantKey || "tenant"
|
||||||
return table[tenantKey]
|
return table[tenantKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRelationConfig(relation: string) {
|
||||||
|
const candidateKeys = [
|
||||||
|
relation,
|
||||||
|
`${relation}s`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (relation.endsWith("y")) {
|
||||||
|
candidateKeys.push(`${relation.slice(0, -1)}ies`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/(s|x|z|ch|sh)$/.test(relation)) {
|
||||||
|
candidateKeys.push(`${relation}es`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of candidateKeys) {
|
||||||
|
if (resourceConfig[key]) return resourceConfig[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function isDateLikeField(key: string) {
|
function isDateLikeField(key: string) {
|
||||||
if (key === "deliveryDateType") return false
|
if (key === "deliveryDateType") return false
|
||||||
if (key.includes("_at") || key.endsWith("At")) return true
|
if (key.includes("_at") || key.endsWith("At")) return true
|
||||||
@@ -176,6 +263,59 @@ function validateMemberPayload(payload: Record<string, any>) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateCostCentreParent(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
costCentreId: string | null,
|
||||||
|
parentCostcentreId: string | null
|
||||||
|
) {
|
||||||
|
if (!parentCostcentreId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchyRows = await server.db
|
||||||
|
.select({
|
||||||
|
id: costcentres.id,
|
||||||
|
parentCostcentre: costcentres.parentCostcentre,
|
||||||
|
})
|
||||||
|
.from(costcentres)
|
||||||
|
.where(eq(costcentres.tenant, tenantId))
|
||||||
|
|
||||||
|
const hierarchyMap = new Map(
|
||||||
|
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hierarchyMap.has(parentCostcentreId)) {
|
||||||
|
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (costCentreId && parentCostcentreId === costCentreId) {
|
||||||
|
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!costCentreId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentParentId: string | null = parentCostcentreId
|
||||||
|
const visited = new Set<string>()
|
||||||
|
|
||||||
|
while (currentParentId) {
|
||||||
|
if (currentParentId === costCentreId) {
|
||||||
|
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.has(currentParentId)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(currentParentId)
|
||||||
|
currentParentId = hierarchyMap.get(currentParentId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function maskIban(iban: string) {
|
function maskIban(iban: string) {
|
||||||
if (!iban) return ""
|
if (!iban) return ""
|
||||||
const cleaned = iban.replace(/\s+/g, "")
|
const cleaned = iban.replace(/\s+/g, "")
|
||||||
@@ -228,6 +368,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) {
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -250,18 +449,23 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
if (!config) {
|
if (!config) {
|
||||||
return reply.code(404).send({ error: "Unknown resource" })
|
return reply.code(404).send({ error: "Unknown resource" })
|
||||||
}
|
}
|
||||||
|
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||||
|
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||||
|
return reply.code(403).send({ error: "Forbidden" })
|
||||||
|
}
|
||||||
const table = config.table
|
const table = config.table
|
||||||
|
|
||||||
const tenantColumn = getTenantColumn(resource, table)
|
const tenantColumn = getTenantColumn(resource, table)
|
||||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||||
|
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||||
let q = server.db.select().from(table).$dynamic()
|
let q = server.db.select().from(table).$dynamic()
|
||||||
|
|
||||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
|
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
|
||||||
|
|
||||||
if (config.mtoLoad) {
|
if (config.mtoLoad) {
|
||||||
config.mtoLoad.forEach(rel => {
|
config.mtoLoad.forEach(rel => {
|
||||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]
|
const relConfig = getRelationConfig(rel)
|
||||||
if (relConfig) {
|
if (relConfig) {
|
||||||
const relTable = relConfig.table
|
const relTable = relConfig.table
|
||||||
|
|
||||||
@@ -307,7 +511,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
||||||
})
|
})
|
||||||
for await (const rel of config.mtoLoad) {
|
for await (const rel of config.mtoLoad) {
|
||||||
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
|
const relConf = getRelationConfig(rel)
|
||||||
|
if (!relConf) continue
|
||||||
const relTab = relConf.table
|
const relTab = relConf.table
|
||||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
|
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
|
||||||
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
||||||
@@ -358,6 +563,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
if (!config) {
|
if (!config) {
|
||||||
return reply.code(404).send({ error: "Unknown resource" });
|
return reply.code(404).send({ error: "Unknown resource" });
|
||||||
}
|
}
|
||||||
|
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||||
|
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||||
|
return reply.code(403).send({ error: "Forbidden" })
|
||||||
|
}
|
||||||
const table = config.table;
|
const table = config.table;
|
||||||
|
|
||||||
const { queryConfig } = req;
|
const { queryConfig } = req;
|
||||||
@@ -367,6 +576,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
const tenantColumn = getTenantColumn(resource, table);
|
const tenantColumn = getTenantColumn(resource, table);
|
||||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
|
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
|
||||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||||
|
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
||||||
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
|
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
|
||||||
const parsedFilters: Array<{ key: string; value: any }> = []
|
const parsedFilters: Array<{ key: string; value: any }> = []
|
||||||
@@ -376,7 +586,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (config.mtoLoad) {
|
if (config.mtoLoad) {
|
||||||
config.mtoLoad.forEach(rel => {
|
config.mtoLoad.forEach(rel => {
|
||||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
const relConfig = getRelationConfig(rel)
|
||||||
if (relConfig) {
|
if (relConfig) {
|
||||||
const relTable = relConfig.table;
|
const relTable = relConfig.table;
|
||||||
|
|
||||||
@@ -457,7 +667,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
|
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
|
||||||
if (config.mtoLoad) {
|
if (config.mtoLoad) {
|
||||||
config.mtoLoad.forEach(rel => {
|
config.mtoLoad.forEach(rel => {
|
||||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
const relConfig = getRelationConfig(rel)
|
||||||
if (!relConfig) return;
|
if (!relConfig) return;
|
||||||
const relTable = relConfig.table;
|
const relTable = relConfig.table;
|
||||||
if (relTable !== table) {
|
if (relTable !== table) {
|
||||||
@@ -467,6 +677,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||||
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
|
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
|
||||||
|
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const searchCond = buildSearchCondition(searchCols, search.trim())
|
const searchCond = buildSearchCondition(searchCols, search.trim())
|
||||||
@@ -496,7 +707,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
||||||
});
|
});
|
||||||
for await (const rel of config.mtoLoad) {
|
for await (const rel of config.mtoLoad) {
|
||||||
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
|
const relConf = getRelationConfig(rel)
|
||||||
|
if (!relConf) continue
|
||||||
const relTab = relConf.table;
|
const relTab = relConf.table;
|
||||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
|
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
|
||||||
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
||||||
@@ -547,10 +759,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
|
||||||
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
|
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
|
||||||
|
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||||
|
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||||
|
return reply.code(403).send({ error: "Forbidden" })
|
||||||
|
}
|
||||||
const table = resourceConfig[resource].table
|
const table = resourceConfig[resource].table
|
||||||
|
|
||||||
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
|
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
|
||||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||||
|
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||||
|
|
||||||
const projRows = await server.db
|
const projRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -567,7 +784,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
if (resourceConfig[resource].mtoLoad) {
|
if (resourceConfig[resource].mtoLoad) {
|
||||||
for await (const relation of resourceConfig[resource].mtoLoad) {
|
for await (const relation of resourceConfig[resource].mtoLoad) {
|
||||||
if (data[relation]) {
|
if (data[relation]) {
|
||||||
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
|
const relConf = getRelationConfig(relation)
|
||||||
|
if (!relConf) continue
|
||||||
const relTable = relConf.table
|
const relTable = relConf.table
|
||||||
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
|
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
|
||||||
data[relation] = relData[0] || null
|
data[relation] = relData[0] || null
|
||||||
@@ -600,6 +818,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
try {
|
try {
|
||||||
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
|
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
|
||||||
const { resource } = req.params as { resource: string };
|
const { resource } = req.params as { resource: string };
|
||||||
|
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||||
|
if (portalCustomerId) {
|
||||||
|
return reply.code(403).send({ error: "Forbidden" })
|
||||||
|
}
|
||||||
if (resource === "accounts") {
|
if (resource === "accounts") {
|
||||||
return reply.code(403).send({ error: "Accounts are read-only" })
|
return reply.code(403).send({ error: "Accounts are read-only" })
|
||||||
}
|
}
|
||||||
@@ -623,6 +845,26 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
createData = prepared.data!
|
createData = prepared.data!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "costcentres") {
|
||||||
|
const validationError = await validateCostCentreParent(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
null,
|
||||||
|
createData.parentCostcentre || null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === "outgoingsepamandates") {
|
||||||
|
const validationError = await validateOutgoingSepaMandatePayload(server, req.user.tenant_id, createData)
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (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)
|
||||||
@@ -636,6 +878,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -679,8 +932,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
const body = req.body as Record<string, any>
|
const body = req.body as Record<string, any>
|
||||||
const tenantId = req.user?.tenant_id
|
const tenantId = req.user?.tenant_id
|
||||||
const userId = req.user?.user_id
|
const userId = req.user?.user_id
|
||||||
|
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||||
|
|
||||||
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
|
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
if (portalCustomerId && resource !== "customers") {
|
||||||
|
return reply.code(403).send({ error: "Forbidden" })
|
||||||
|
}
|
||||||
|
|
||||||
const table = resourceConfig[resource].table
|
const table = resourceConfig[resource].table
|
||||||
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; }
|
||||||
@@ -688,13 +945,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
const [oldRecord] = await server.db
|
const [oldRecord] = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(table)
|
.from(table)
|
||||||
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
|
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
|
if (!oldRecord) {
|
||||||
|
return reply.code(404).send({ error: "Resource not found" })
|
||||||
|
}
|
||||||
|
|
||||||
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
|
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
delete data.updatedBy; delete data.updatedAt;
|
delete data.updatedBy; delete data.updatedAt;
|
||||||
|
|
||||||
|
if (portalCustomerId) {
|
||||||
|
data = {
|
||||||
|
...sanitizePortalCustomerUpdate(data),
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
updated_by: data.updated_by,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (resource === "members") {
|
if (resource === "members") {
|
||||||
data = normalizeMemberPayload(data)
|
data = normalizeMemberPayload(data)
|
||||||
const validationError = validateMemberPayload(data)
|
const validationError = validateMemberPayload(data)
|
||||||
@@ -713,6 +982,28 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "costcentres") {
|
||||||
|
const validationError = await validateCostCentreParent(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
oldRecord.id,
|
||||||
|
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
|
||||||
|
? data.parentCostcentre || null
|
||||||
|
: oldRecord.parentCostcentre || null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === "outgoingsepamandates") {
|
||||||
|
const validationError = await validateOutgoingSepaMandatePayload(server, tenantId, data, oldRecord)
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
const value = data[key]
|
const value = data[key]
|
||||||
const shouldNormalize =
|
const shouldNormalize =
|
||||||
@@ -730,6 +1021,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { sortData } from "../utils/sort"
|
|||||||
|
|
||||||
// Schema imports
|
// Schema imports
|
||||||
import { accounts, units, countrys, tenants } from "../../db/schema"
|
import { accounts, units, countrys, tenants } from "../../db/schema"
|
||||||
|
import { defaultCountries } from "../utils/countries"
|
||||||
|
|
||||||
const TABLE_MAP: Record<string, any> = {
|
const TABLE_MAP: Record<string, any> = {
|
||||||
accounts,
|
accounts,
|
||||||
@@ -96,6 +97,24 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
|
|||||||
|
|
||||||
const data = await query
|
const data = await query
|
||||||
|
|
||||||
|
if (resource === "countrys") {
|
||||||
|
const countryMap = new Map<string, any>()
|
||||||
|
|
||||||
|
for (const country of defaultCountries) {
|
||||||
|
countryMap.set(country.toLocaleLowerCase("de"), { id: country, name: country })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const country of data) {
|
||||||
|
countryMap.set(country.name.toLocaleLowerCase("de"), country)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortData(
|
||||||
|
Array.from(countryMap.values()),
|
||||||
|
sort || "name",
|
||||||
|
sort ? ascQuery === "true" : true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Falls sort clientseitig wie früher notwendig ist:
|
// Falls sort clientseitig wie früher notwendig ist:
|
||||||
const sorted = sortData(
|
const sorted = sortData(
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
desc
|
desc
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
@@ -354,7 +354,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
|||||||
const combinedEvents = [
|
const combinedEvents = [
|
||||||
...factualEvents,
|
...factualEvents,
|
||||||
...relatedAdminEvents,
|
...relatedAdminEvents,
|
||||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
].sort(compareTimeEvents);
|
||||||
|
|
||||||
// SCHRITT 5: Spans ableiten
|
// SCHRITT 5: Spans ableiten
|
||||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
@@ -424,7 +424,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
|||||||
const combinedEvents = [
|
const combinedEvents = [
|
||||||
...factualEvents,
|
...factualEvents,
|
||||||
...relatedAdminEvents,
|
...relatedAdminEvents,
|
||||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
].sort(compareTimeEvents);
|
||||||
|
|
||||||
// SCHRITT 4: Ableiten und Anreichern
|
// SCHRITT 4: Ableiten und Anreichern
|
||||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
@@ -453,4 +453,4 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||||
|
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||||
|
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutes(server: FastifyInstance) {
|
export default async function tenantRoutes(server: FastifyInstance) {
|
||||||
@@ -123,7 +125,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
.where(inArray(authUsers.id, userIds))
|
.where(inArray(authUsers.id, userIds))
|
||||||
|
|
||||||
// 3) auth_profiles pro Tenant laden
|
// 3) auth_profiles pro Tenant laden
|
||||||
const profiles = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(
|
.where(
|
||||||
@@ -131,6 +133,8 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
eq(authProfiles.tenant_id, tenantId),
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
inArray(authProfiles.user_id, userIds)
|
inArray(authProfiles.user_id, userIds)
|
||||||
))
|
))
|
||||||
|
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||||
|
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||||
|
|
||||||
const combined = users.map(u => {
|
const combined = users.map(u => {
|
||||||
const profile = profiles.find(p => p.user_id === u.id)
|
const profile = profiles.find(p => p.user_id === u.id)
|
||||||
@@ -160,11 +164,13 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
const tenantId = req.user?.tenant_id
|
const tenantId = req.user?.tenant_id
|
||||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
const data = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(eq(authProfiles.tenant_id, tenantId))
|
.where(eq(authProfiles.tenant_id, tenantId))
|
||||||
|
|
||||||
|
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||||
|
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||||
return { data }
|
return { data }
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
258
backend/src/utils/countries.ts
Normal file
258
backend/src/utils/countries.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
const COUNTRY_CODES = [
|
||||||
|
"AF",
|
||||||
|
"AX",
|
||||||
|
"AL",
|
||||||
|
"DZ",
|
||||||
|
"AS",
|
||||||
|
"AD",
|
||||||
|
"AO",
|
||||||
|
"AI",
|
||||||
|
"AQ",
|
||||||
|
"AG",
|
||||||
|
"AR",
|
||||||
|
"AM",
|
||||||
|
"AW",
|
||||||
|
"AU",
|
||||||
|
"AT",
|
||||||
|
"AZ",
|
||||||
|
"BS",
|
||||||
|
"BH",
|
||||||
|
"BD",
|
||||||
|
"BB",
|
||||||
|
"BY",
|
||||||
|
"BE",
|
||||||
|
"BZ",
|
||||||
|
"BJ",
|
||||||
|
"BM",
|
||||||
|
"BT",
|
||||||
|
"BO",
|
||||||
|
"BQ",
|
||||||
|
"BA",
|
||||||
|
"BW",
|
||||||
|
"BV",
|
||||||
|
"BR",
|
||||||
|
"IO",
|
||||||
|
"BN",
|
||||||
|
"BG",
|
||||||
|
"BF",
|
||||||
|
"BI",
|
||||||
|
"CV",
|
||||||
|
"KH",
|
||||||
|
"CM",
|
||||||
|
"CA",
|
||||||
|
"KY",
|
||||||
|
"CF",
|
||||||
|
"TD",
|
||||||
|
"CL",
|
||||||
|
"CN",
|
||||||
|
"CX",
|
||||||
|
"CC",
|
||||||
|
"CO",
|
||||||
|
"KM",
|
||||||
|
"CG",
|
||||||
|
"CD",
|
||||||
|
"CK",
|
||||||
|
"CR",
|
||||||
|
"CI",
|
||||||
|
"HR",
|
||||||
|
"CU",
|
||||||
|
"CW",
|
||||||
|
"CY",
|
||||||
|
"CZ",
|
||||||
|
"DK",
|
||||||
|
"DJ",
|
||||||
|
"DM",
|
||||||
|
"DO",
|
||||||
|
"EC",
|
||||||
|
"EG",
|
||||||
|
"SV",
|
||||||
|
"GQ",
|
||||||
|
"ER",
|
||||||
|
"EE",
|
||||||
|
"SZ",
|
||||||
|
"ET",
|
||||||
|
"FK",
|
||||||
|
"FO",
|
||||||
|
"FJ",
|
||||||
|
"FI",
|
||||||
|
"FR",
|
||||||
|
"GF",
|
||||||
|
"PF",
|
||||||
|
"TF",
|
||||||
|
"GA",
|
||||||
|
"GM",
|
||||||
|
"GE",
|
||||||
|
"DE",
|
||||||
|
"GH",
|
||||||
|
"GI",
|
||||||
|
"GR",
|
||||||
|
"GL",
|
||||||
|
"GD",
|
||||||
|
"GP",
|
||||||
|
"GU",
|
||||||
|
"GT",
|
||||||
|
"GG",
|
||||||
|
"GN",
|
||||||
|
"GW",
|
||||||
|
"GY",
|
||||||
|
"HT",
|
||||||
|
"HM",
|
||||||
|
"VA",
|
||||||
|
"HN",
|
||||||
|
"HK",
|
||||||
|
"HU",
|
||||||
|
"IS",
|
||||||
|
"IN",
|
||||||
|
"ID",
|
||||||
|
"IR",
|
||||||
|
"IQ",
|
||||||
|
"IE",
|
||||||
|
"IM",
|
||||||
|
"IL",
|
||||||
|
"IT",
|
||||||
|
"JM",
|
||||||
|
"JP",
|
||||||
|
"JE",
|
||||||
|
"JO",
|
||||||
|
"KZ",
|
||||||
|
"KE",
|
||||||
|
"KI",
|
||||||
|
"KP",
|
||||||
|
"KR",
|
||||||
|
"KW",
|
||||||
|
"KG",
|
||||||
|
"LA",
|
||||||
|
"LV",
|
||||||
|
"LB",
|
||||||
|
"LS",
|
||||||
|
"LR",
|
||||||
|
"LY",
|
||||||
|
"LI",
|
||||||
|
"LT",
|
||||||
|
"LU",
|
||||||
|
"MO",
|
||||||
|
"MG",
|
||||||
|
"MW",
|
||||||
|
"MY",
|
||||||
|
"MV",
|
||||||
|
"ML",
|
||||||
|
"MT",
|
||||||
|
"MH",
|
||||||
|
"MQ",
|
||||||
|
"MR",
|
||||||
|
"MU",
|
||||||
|
"YT",
|
||||||
|
"MX",
|
||||||
|
"FM",
|
||||||
|
"MD",
|
||||||
|
"MC",
|
||||||
|
"MN",
|
||||||
|
"ME",
|
||||||
|
"MS",
|
||||||
|
"MA",
|
||||||
|
"MZ",
|
||||||
|
"MM",
|
||||||
|
"NA",
|
||||||
|
"NR",
|
||||||
|
"NP",
|
||||||
|
"NL",
|
||||||
|
"NC",
|
||||||
|
"NZ",
|
||||||
|
"NI",
|
||||||
|
"NE",
|
||||||
|
"NG",
|
||||||
|
"NU",
|
||||||
|
"NF",
|
||||||
|
"MK",
|
||||||
|
"MP",
|
||||||
|
"NO",
|
||||||
|
"OM",
|
||||||
|
"PK",
|
||||||
|
"PW",
|
||||||
|
"PS",
|
||||||
|
"PA",
|
||||||
|
"PG",
|
||||||
|
"PY",
|
||||||
|
"PE",
|
||||||
|
"PH",
|
||||||
|
"PN",
|
||||||
|
"PL",
|
||||||
|
"PT",
|
||||||
|
"PR",
|
||||||
|
"QA",
|
||||||
|
"RE",
|
||||||
|
"RO",
|
||||||
|
"RU",
|
||||||
|
"RW",
|
||||||
|
"BL",
|
||||||
|
"SH",
|
||||||
|
"KN",
|
||||||
|
"LC",
|
||||||
|
"MF",
|
||||||
|
"PM",
|
||||||
|
"VC",
|
||||||
|
"WS",
|
||||||
|
"SM",
|
||||||
|
"ST",
|
||||||
|
"SA",
|
||||||
|
"SN",
|
||||||
|
"RS",
|
||||||
|
"SC",
|
||||||
|
"SL",
|
||||||
|
"SG",
|
||||||
|
"SX",
|
||||||
|
"SK",
|
||||||
|
"SI",
|
||||||
|
"SB",
|
||||||
|
"SO",
|
||||||
|
"ZA",
|
||||||
|
"GS",
|
||||||
|
"SS",
|
||||||
|
"ES",
|
||||||
|
"LK",
|
||||||
|
"SD",
|
||||||
|
"SR",
|
||||||
|
"SJ",
|
||||||
|
"SE",
|
||||||
|
"CH",
|
||||||
|
"SY",
|
||||||
|
"TW",
|
||||||
|
"TJ",
|
||||||
|
"TZ",
|
||||||
|
"TH",
|
||||||
|
"TL",
|
||||||
|
"TG",
|
||||||
|
"TK",
|
||||||
|
"TO",
|
||||||
|
"TT",
|
||||||
|
"TN",
|
||||||
|
"TR",
|
||||||
|
"TM",
|
||||||
|
"TC",
|
||||||
|
"TV",
|
||||||
|
"UG",
|
||||||
|
"UA",
|
||||||
|
"AE",
|
||||||
|
"GB",
|
||||||
|
"UM",
|
||||||
|
"US",
|
||||||
|
"UY",
|
||||||
|
"UZ",
|
||||||
|
"VU",
|
||||||
|
"VE",
|
||||||
|
"VN",
|
||||||
|
"VG",
|
||||||
|
"VI",
|
||||||
|
"WF",
|
||||||
|
"EH",
|
||||||
|
"YE",
|
||||||
|
"ZM",
|
||||||
|
"ZW",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const countryNames = new Intl.DisplayNames(["de"], { type: "region" })
|
||||||
|
|
||||||
|
export const defaultCountries = COUNTRY_CODES
|
||||||
|
.map((code) => countryNames.of(code))
|
||||||
|
.filter((name): name is string => Boolean(name))
|
||||||
|
.sort((a, b) => a.localeCompare(b, "de"))
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import xmlbuilder from "xmlbuilder";
|
import xmlbuilder from "xmlbuilder";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import isBetween from "dayjs/plugin/isBetween.js";
|
import isBetween from "dayjs/plugin/isBetween.js";
|
||||||
|
import utc from "dayjs/plugin/utc.js";
|
||||||
|
import timezone from "dayjs/plugin/timezone.js";
|
||||||
import { BlobWriter, Data64URIReader, TextReader, ZipWriter } from "@zip.js/zip.js";
|
import { BlobWriter, Data64URIReader, TextReader, ZipWriter } from "@zip.js/zip.js";
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
@@ -8,7 +10,7 @@ import { s3 } from "../s3";
|
|||||||
import { secrets } from "../secrets";
|
import { secrets } from "../secrets";
|
||||||
|
|
||||||
// Drizzle Core Imports
|
// Drizzle Core Imports
|
||||||
import { eq, and, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
|
import { eq, and, or, isNull, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
|
||||||
|
|
||||||
// Tabellen Imports (keine Relations nötig!)
|
// Tabellen Imports (keine Relations nötig!)
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +27,8 @@ import {
|
|||||||
} from "../../../db/schema";
|
} from "../../../db/schema";
|
||||||
|
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// HELPER FUNCTIONS (Unverändert)
|
// HELPER FUNCTIONS (Unverändert)
|
||||||
@@ -34,17 +38,28 @@ const getCreatedDocumentTotal = (item: any) => {
|
|||||||
let totalNet = 0;
|
let totalNet = 0;
|
||||||
let total19:number = 0;
|
let total19:number = 0;
|
||||||
let total7:number = 0;
|
let total7:number = 0;
|
||||||
|
let net19 = 0;
|
||||||
|
let net7 = 0;
|
||||||
|
let net0 = 0;
|
||||||
const rows = Array.isArray(item.rows) ? item.rows : [];
|
const rows = Array.isArray(item.rows) ? item.rows : [];
|
||||||
rows.forEach((row: any) => {
|
rows.forEach((row: any) => {
|
||||||
if (!['pagebreak', 'title', 'text'].includes(row.mode)) {
|
if (!['pagebreak', 'title', 'text'].includes(row.mode)) {
|
||||||
|
const taxPercent = Number(row.taxPercent);
|
||||||
let rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)).toFixed(3);
|
let rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)).toFixed(3);
|
||||||
totalNet = totalNet + Number(rowPrice);
|
totalNet = totalNet + Number(rowPrice);
|
||||||
if (row.taxPercent === 19) total19 += Number(rowPrice) * Number(0.19);
|
if (taxPercent === 19) {
|
||||||
else if (row.taxPercent === 7) total7 += Number(rowPrice) * Number(0.07);
|
net19 += Number(rowPrice);
|
||||||
|
total19 += Number(rowPrice) * Number(0.19);
|
||||||
|
} else if (taxPercent === 7) {
|
||||||
|
net7 += Number(rowPrice);
|
||||||
|
total7 += Number(rowPrice) * Number(0.07);
|
||||||
|
} else if (taxPercent === 0) {
|
||||||
|
net0 += Number(rowPrice);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
totalNet, total19, total7,
|
totalNet, total19, total7, net19, net7, net0,
|
||||||
totalGross: Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
|
totalGross: Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -57,6 +72,32 @@ const displayCurrency = (input: number, onlyAbs = false) => {
|
|||||||
return (onlyAbs ? Math.abs(input) : input).toFixed(2).replace(".", ",");
|
return (onlyAbs ? Math.abs(input) : input).toFixed(2).replace(".", ",");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DATEV_TIMEZONE = "Europe/Berlin";
|
||||||
|
|
||||||
|
const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
|
||||||
|
if (!date) return "";
|
||||||
|
const parsed = dayjs(date);
|
||||||
|
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCreatedDocumentRevenueLines = (document: any) => {
|
||||||
|
const totals = getCreatedDocumentTotal(document);
|
||||||
|
|
||||||
|
if (document.taxType === "13b UStG") {
|
||||||
|
return [{ account: "8337", amount: totals.totalGross }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.taxType === "19 UStG") {
|
||||||
|
return [{ account: "8192", amount: totals.totalGross }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ account: "8400", amount: Number(totals.net19.toFixed(2)) },
|
||||||
|
{ account: "8334", amount: Number(totals.net7.toFixed(2)) },
|
||||||
|
{ account: "8290", amount: Number(totals.net0.toFixed(2)) },
|
||||||
|
].filter((line) => line.amount !== 0);
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// MAIN EXPORT FUNCTION
|
// MAIN EXPORT FUNCTION
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -76,8 +117,10 @@ export async function buildExportZip(
|
|||||||
|
|
||||||
// Header Infos
|
// Header Infos
|
||||||
const dateNowStr = dayjs().format("YYYYMMDDHHmmssSSS");
|
const dateNowStr = dayjs().format("YYYYMMDDHHmmssSSS");
|
||||||
const startDateFmt = dayjs(startDate).format("YYYYMMDD");
|
const startDateValue = formatDatevDate(startDate, "YYYY-MM-DD");
|
||||||
const endDateFmt = dayjs(endDate).format("YYYYMMDD");
|
const endDateValue = formatDatevDate(endDate, "YYYY-MM-DD");
|
||||||
|
const startDateFmt = formatDatevDate(startDate, "YYYYMMDD");
|
||||||
|
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD");
|
||||||
|
|
||||||
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
|
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
|
||||||
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
|
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
|
||||||
@@ -99,8 +142,8 @@ export async function buildExportZip(
|
|||||||
inArray(createddocuments.type, ["invoices", "advanceInvoices", "cancellationInvoices"]),
|
inArray(createddocuments.type, ["invoices", "advanceInvoices", "cancellationInvoices"]),
|
||||||
eq(createddocuments.state, "Gebucht"),
|
eq(createddocuments.state, "Gebucht"),
|
||||||
eq(createddocuments.archived, false),
|
eq(createddocuments.archived, false),
|
||||||
gte(createddocuments.documentDate, startDate),
|
gte(createddocuments.documentDate, startDateValue),
|
||||||
lte(createddocuments.documentDate, endDate)
|
lte(createddocuments.documentDate, endDateValue)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Mapping: Flat Result -> Nested Object (damit der Rest des Codes gleich bleiben kann)
|
// Mapping: Flat Result -> Nested Object (damit der Rest des Codes gleich bleiben kann)
|
||||||
@@ -121,8 +164,8 @@ export async function buildExportZip(
|
|||||||
eq(incominginvoices.tenant, tenantId),
|
eq(incominginvoices.tenant, tenantId),
|
||||||
eq(incominginvoices.state, "Gebucht"),
|
eq(incominginvoices.state, "Gebucht"),
|
||||||
eq(incominginvoices.archived, false),
|
eq(incominginvoices.archived, false),
|
||||||
gte(incominginvoices.date, startDate),
|
gte(incominginvoices.date, startDateValue),
|
||||||
lte(incominginvoices.date, endDate)
|
lte(incominginvoices.date, endDateValue)
|
||||||
));
|
));
|
||||||
|
|
||||||
const incominginvoicesList = iiRaw.map(r => ({
|
const incominginvoicesList = iiRaw.map(r => ({
|
||||||
@@ -136,6 +179,10 @@ export async function buildExportZip(
|
|||||||
|
|
||||||
const CdCustomer = aliasedTable(customers, "cd_customer");
|
const CdCustomer = aliasedTable(customers, "cd_customer");
|
||||||
const IiVendor = aliasedTable(vendors, "ii_vendor");
|
const IiVendor = aliasedTable(vendors, "ii_vendor");
|
||||||
|
const ContraAccount = aliasedTable(accounts, "contra_account");
|
||||||
|
const ContraVendor = aliasedTable(vendors, "contra_vendor");
|
||||||
|
const ContraCustomer = aliasedTable(customers, "contra_customer");
|
||||||
|
const ContraOwnaccount = aliasedTable(ownaccounts, "contra_ownaccount");
|
||||||
|
|
||||||
const allocRaw = await server.db.select({
|
const allocRaw = await server.db.select({
|
||||||
allocation: statementallocations,
|
allocation: statementallocations,
|
||||||
@@ -148,11 +195,15 @@ export async function buildExportZip(
|
|||||||
acc: accounts,
|
acc: accounts,
|
||||||
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
||||||
direct_cust: customers, // Direkte Zuordnung an Debitor
|
direct_cust: customers, // Direkte Zuordnung an Debitor
|
||||||
own: ownaccounts
|
own: ownaccounts,
|
||||||
|
contra_acc: ContraAccount,
|
||||||
|
contra_vend: ContraVendor,
|
||||||
|
contra_cust: ContraCustomer,
|
||||||
|
contra_own: ContraOwnaccount
|
||||||
})
|
})
|
||||||
.from(statementallocations)
|
.from(statementallocations)
|
||||||
// JOIN 1: Bankstatement (Pflicht, für Datum Filter)
|
// JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz)
|
||||||
.innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
|
.leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
|
||||||
// JOIN 2: Bankaccount (für DATEV Nummer)
|
// JOIN 2: Bankaccount (für DATEV Nummer)
|
||||||
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
|
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
|
||||||
|
|
||||||
@@ -169,13 +220,25 @@ export async function buildExportZip(
|
|||||||
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||||
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||||
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||||
|
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
|
||||||
|
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
|
||||||
|
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
|
||||||
|
.leftJoin(ContraOwnaccount, eq(statementallocations.contraOwnaccount, ContraOwnaccount.id))
|
||||||
|
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(statementallocations.tenant, tenantId),
|
eq(statementallocations.tenant, tenantId),
|
||||||
eq(statementallocations.archived, false),
|
eq(statementallocations.archived, false),
|
||||||
// Datum Filter direkt auf dem Bankstatement
|
or(
|
||||||
gte(bankstatements.date, startDate),
|
and(
|
||||||
lte(bankstatements.date, endDate)
|
gte(bankstatements.date, startDateValue),
|
||||||
|
lte(bankstatements.date, endDateValue)
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(statementallocations.bankstatement),
|
||||||
|
gte(statementallocations.manualBookingDate, startDateValue),
|
||||||
|
lte(statementallocations.manualBookingDate, endDateValue)
|
||||||
|
)
|
||||||
|
)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
|
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
|
||||||
@@ -196,7 +259,11 @@ export async function buildExportZip(
|
|||||||
account: r.acc,
|
account: r.acc,
|
||||||
vendor: r.direct_vend,
|
vendor: r.direct_vend,
|
||||||
customer: r.direct_cust,
|
customer: r.direct_cust,
|
||||||
ownaccount: r.own
|
ownaccount: r.own,
|
||||||
|
contraAccount: r.contra_acc,
|
||||||
|
contraVendor: r.contra_vend,
|
||||||
|
contraCustomer: r.contra_cust,
|
||||||
|
contraOwnaccount: r.contra_own
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// --- D) Stammdaten Accounts ---
|
// --- D) Stammdaten Accounts ---
|
||||||
@@ -265,24 +332,23 @@ export async function buildExportZip(
|
|||||||
// AR
|
// AR
|
||||||
createddocumentsList.forEach(cd => {
|
createddocumentsList.forEach(cd => {
|
||||||
let file = filesCreateddocuments.find(i => i.createddocument === cd.id);
|
let file = filesCreateddocuments.find(i => i.createddocument === cd.id);
|
||||||
let total = 0;
|
|
||||||
let typeString = "";
|
let typeString = "";
|
||||||
|
|
||||||
if(cd.type === "invoices") {
|
if(cd.type === "invoices") {
|
||||||
total = getCreatedDocumentTotal(cd).totalGross;
|
|
||||||
typeString = "AR";
|
typeString = "AR";
|
||||||
} else if(cd.type === "advanceInvoices") {
|
} else if(cd.type === "advanceInvoices") {
|
||||||
total = getCreatedDocumentTotal(cd).totalGross;
|
|
||||||
typeString = "ARAbschlag";
|
typeString = "ARAbschlag";
|
||||||
} else if(cd.type === "cancellationInvoices") {
|
} else if(cd.type === "cancellationInvoices") {
|
||||||
total = getCreatedDocumentTotal(cd).totalGross;
|
|
||||||
typeString = "ARStorno";
|
typeString = "ARStorno";
|
||||||
}
|
}
|
||||||
|
|
||||||
let shSelector = Math.sign(total) === -1 ? "H" : "S";
|
|
||||||
const cust = cd.customer; // durch Mapping verfügbar
|
const cust = cd.customer; // durch Mapping verfügbar
|
||||||
|
const revenueLines = getCreatedDocumentRevenueLines(cd);
|
||||||
|
|
||||||
bookingLines.push(`${displayCurrency(total,true)};"${shSelector}";;;;;${cust?.customerNumber || ""};8400;"";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`${typeString} ${cd.documentNumber} - ${cust?.name || ""}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${cust?.name || ""}";"Kundennummer";"${cust?.customerNumber || ""}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(cd.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
revenueLines.forEach((revenueLine) => {
|
||||||
|
let shSelector = Math.sign(revenueLine.amount) === -1 ? "H" : "S";
|
||||||
|
bookingLines.push(`${displayCurrency(revenueLine.amount,true)};"${shSelector}";;;;;${cust?.customerNumber || ""};${revenueLine.account};"";${formatDatevDate(cd.documentDate, "DDMM")};"${cd.documentNumber}";;;"${`${typeString} ${cd.documentNumber} - ${cust?.name || ""}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${cust?.name || ""}";"Kundennummer";"${cust?.customerNumber || ""}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${formatDatevDate(cd.deliveryDate, "DD.MM.YYYY")}";"Belegdatum";"${formatDatevDate(cd.documentDate, "DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ER
|
// ER
|
||||||
@@ -306,30 +372,64 @@ export async function buildExportZip(
|
|||||||
let text = `ER ${ii.reference}: ${escapeString(ii.description)}`.substring(0,59);
|
let text = `ER ${ii.reference}: ${escapeString(ii.description)}`.substring(0,59);
|
||||||
const vend = ii.vendor; // durch Mapping verfügbar
|
const vend = ii.vendor; // durch Mapping verfügbar
|
||||||
|
|
||||||
bookingLines.push(`${Math.abs(amountGross).toFixed(2).replace(".",",")};"${shSelector}";;;;;${accountData.number};${vend?.vendorNumber || ""};"${buschluessel}";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${text}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${vend?.name || ""}";"Kundennummer";"${vend?.vendorNumber || ""}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
bookingLines.push(`${Math.abs(amountGross).toFixed(2).replace(".",",")};"${shSelector}";;;;;${accountData.number};${vend?.vendorNumber || ""};"${buschluessel}";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${text}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${vend?.name || ""}";"Kundennummer";"${vend?.vendorNumber || ""}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bank
|
// Bank
|
||||||
|
const getManualBookingSide = (alloc: any, side: "debit" | "credit") => {
|
||||||
|
const prefix = side === "credit" ? "contra" : "";
|
||||||
|
const account = side === "credit" ? alloc.contraAccount : alloc.account;
|
||||||
|
const vendor = side === "credit" ? alloc.contraVendor : alloc.vendor;
|
||||||
|
const customer = side === "credit" ? alloc.contraCustomer : alloc.customer;
|
||||||
|
const ownaccount = side === "credit" ? alloc.contraOwnaccount : alloc.ownaccount;
|
||||||
|
const incominginvoice = alloc.manualInvoiceSide === side ? alloc.incominginvoice : null;
|
||||||
|
|
||||||
|
if (account) return { number: account.number, name: account.label, type: "Sachkonto" };
|
||||||
|
if (vendor) return { number: vendor.vendorNumber, name: vendor.name, type: "Kreditor" };
|
||||||
|
if (customer) return { number: customer.customerNumber, name: customer.name, type: "Debitor" };
|
||||||
|
if (ownaccount) return { number: ownaccount.number, name: ownaccount.name, type: "Eigenes Konto" };
|
||||||
|
if (incominginvoice) {
|
||||||
|
return {
|
||||||
|
number: incominginvoice.vendor?.vendorNumber || "",
|
||||||
|
name: `${incominginvoice.reference || "Eingangsbeleg"} ${incominginvoice.vendor?.name || ""}`.trim(),
|
||||||
|
type: "Eingangsbeleg",
|
||||||
|
reference: incominginvoice.reference || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { number: "", name: "", type: prefix };
|
||||||
|
};
|
||||||
|
|
||||||
statementallocationsList.forEach(alloc => {
|
statementallocationsList.forEach(alloc => {
|
||||||
const bs = alloc.bankstatement; // durch Mapping verfügbar
|
const bs = alloc.bankstatement; // durch Mapping verfügbar
|
||||||
|
|
||||||
|
if(!bs && alloc.manualBookingDate) {
|
||||||
|
const debit = getManualBookingSide(alloc, "debit");
|
||||||
|
const credit = getManualBookingSide(alloc, "credit");
|
||||||
|
const dateManual = formatDatevDate(alloc.manualBookingDate, "DDMM");
|
||||||
|
const dateManualFull = formatDatevDate(alloc.manualBookingDate, "DD.MM.YYYY");
|
||||||
|
const belegnummer = debit.reference || credit.reference || "";
|
||||||
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"${alloc.datevTaxKey || ""}";${dateManual};"${belegnummer}";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"${belegnummer}";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if(!bs) return;
|
if(!bs) return;
|
||||||
|
|
||||||
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
|
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
let datevKonto = bs.account?.datevNumber || "";
|
let datevKonto = bs.account?.datevNumber || "";
|
||||||
let dateVal = dayjs(bs.date).format("DDMM");
|
let dateVal = formatDatevDate(bs.date, "DDMM");
|
||||||
let dateFull = dayjs(bs.date).format("DD.MM.YYYY");
|
let dateFull = formatDatevDate(bs.date, "DD.MM.YYYY");
|
||||||
let bsText = escapeString(bs.text);
|
let bsText = escapeString(bs.text);
|
||||||
|
|
||||||
if(alloc.createddocument && alloc.createddocument.customer) {
|
if(alloc.createddocument && alloc.createddocument.customer) {
|
||||||
const cd = alloc.createddocument;
|
const cd = alloc.createddocument;
|
||||||
const cust = cd.customer;
|
const cust = cd.customer;
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"3";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"";${formatDatevDate(cd.documentDate, "DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${formatDatevDate(cd.deliveryDate, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
|
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
|
||||||
const ii = alloc.incominginvoice;
|
const ii = alloc.incominginvoice;
|
||||||
const vend = ii.vendor;
|
const vend = ii.vendor;
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
} else if(alloc.account) {
|
} else if(alloc.account) {
|
||||||
const acc = alloc.account;
|
const acc = alloc.account;
|
||||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||||
@@ -425,4 +525,4 @@ export async function buildExportZip(
|
|||||||
console.error("DATEV Export Error:", error);
|
console.error("DATEV Export Error:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +1,268 @@
|
|||||||
import xmlbuilder from "xmlbuilder";
|
import xmlbuilder from "xmlbuilder";
|
||||||
import {randomUUID} from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { createddocuments, tenants } from "../../../db/schema";
|
import {
|
||||||
|
bankaccounts,
|
||||||
|
createddocuments,
|
||||||
|
customers,
|
||||||
|
entitybankaccounts,
|
||||||
|
outgoingsepamandates,
|
||||||
|
tenants,
|
||||||
|
} from "../../../db/schema";
|
||||||
|
import { decrypt } from "../crypt";
|
||||||
|
|
||||||
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
|
const getCreatedDocumentTotal = (item: any) => {
|
||||||
const data = await server.db
|
let totalNet = 0;
|
||||||
.select()
|
let total19 = 0;
|
||||||
.from(createddocuments)
|
let total7 = 0;
|
||||||
.where(and(
|
|
||||||
eq(createddocuments.tenant, tenant_id),
|
|
||||||
inArray(createddocuments.id, idsToExport)
|
|
||||||
))
|
|
||||||
|
|
||||||
const tenantRows = await server.db
|
const rows = Array.isArray(item.rows) ? item.rows : [];
|
||||||
.select()
|
rows.forEach((row: any) => {
|
||||||
.from(tenants)
|
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
||||||
.where(eq(tenants.id, tenant_id))
|
const rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)).toFixed(3);
|
||||||
.limit(1)
|
totalNet += Number(rowPrice);
|
||||||
const tenantData = tenantRows[0]
|
|
||||||
console.log(tenantData)
|
|
||||||
|
|
||||||
console.log(data)
|
if (Number(row.taxPercent) === 19) {
|
||||||
|
total19 += Number(rowPrice) * 0.19;
|
||||||
let transactions = []
|
} else if (Number(row.taxPercent) === 7) {
|
||||||
|
total7 += Number(rowPrice) * 0.07;
|
||||||
let obj = {
|
|
||||||
Document: {
|
|
||||||
'@xmlns':"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
|
|
||||||
'CstmrDrctDbtInitn': {
|
|
||||||
'GrpHdr': {
|
|
||||||
'MsgId': randomUUID(),
|
|
||||||
'CredDtTm': dayjs().format("YYYY-MM-DDTHH:mm:ss"),
|
|
||||||
'NbOfTxs': transactions.length,
|
|
||||||
'CtrlSum': 0, // TODO: Total Sum
|
|
||||||
'InitgPty': {
|
|
||||||
'Nm': tenantData.name
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'PmtInf': {
|
|
||||||
'PmtInfId': "", // TODO: Mandatsreferenz,
|
|
||||||
'PmtMtd': "DD",
|
|
||||||
'BtchBookg': "true", // TODO: BatchBooking,
|
|
||||||
'NbOfTxs': transactions.length,
|
|
||||||
'CtrlSum': 0, //TODO: Total Sum
|
|
||||||
'PmtTpInf': {
|
|
||||||
'SvcLvl': {
|
|
||||||
'Cd': "SEPA"
|
|
||||||
},
|
|
||||||
'LclInstrm': {
|
|
||||||
'Cd': "CORE" // Core für BASIS / B2B für Firmen
|
|
||||||
},
|
|
||||||
'SeqTp': "RCUR" // TODO: Unterscheidung Einmalig / Wiederkehrend
|
|
||||||
},
|
|
||||||
'ReqdColltnDt': dayjs().add(3, "days").format("YYYY-MM-DDTHH:mm:ss"),
|
|
||||||
'Cdtr': {
|
|
||||||
'Nm': tenantData.name
|
|
||||||
},
|
|
||||||
'CdtrAcct': {
|
|
||||||
'Id': {
|
|
||||||
'IBAN': "DE" // TODO: IBAN Gläubiger EINSETZEN
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'CdtrAgt': {
|
|
||||||
'FinInstnId': {
|
|
||||||
'BIC': "BIC" // TODO: BIC des Gläubigers einsetzen
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'ChrgBr': "SLEV",
|
|
||||||
'CdtrSchmeId': {
|
|
||||||
'Id': {
|
|
||||||
'PrvtId': {
|
|
||||||
'Othr': {
|
|
||||||
'Id': tenantData.creditorId,
|
|
||||||
'SchmeNm': {
|
|
||||||
'Prty': "SEPA"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
//TODO ITERATE ALL INVOICES HERE
|
|
||||||
'DrctDbtTxInf': {
|
|
||||||
'PmtId': {
|
|
||||||
'EndToEndId': "" // TODO: EINDEUTIGE ReFERENZ wahrscheinlich RE Nummer
|
|
||||||
},
|
|
||||||
'InstdAmt': {
|
|
||||||
'@Ccy':"EUR",
|
|
||||||
'#text':100 //TODO: Rechnungssumme zwei NK mit Punkt
|
|
||||||
},
|
|
||||||
'DrctDbtTx': {
|
|
||||||
'MndtRltdInf': {
|
|
||||||
'MndtId': "", // TODO: Mandatsref,
|
|
||||||
'DtOfSgntr': "", //TODO: Unterschrieben am,
|
|
||||||
'AmdmntInd': "" //TODO: Mandat geändert
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'DbtrAgt': {
|
|
||||||
'FinInstnId': {
|
|
||||||
'BIC': "", //TODO: BIC Debtor
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'Dbtr': {
|
|
||||||
'Nm': "" // TODO NAME Debtor
|
|
||||||
},
|
|
||||||
'DbtrAcct': {
|
|
||||||
'Id': {
|
|
||||||
'IBAN': "DE" // TODO IBAN Debtor
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'RmtInf': {
|
|
||||||
'Ustrd': "" //TODO Verwendungszweck Rechnungsnummer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number((Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))).toFixed(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanIban = (value: string | null | undefined) => (value || "").replace(/\s+/g, "").toUpperCase();
|
||||||
|
const cleanBic = (value: string | null | undefined) => (value || "").replace(/\s+/g, "").toUpperCase();
|
||||||
|
|
||||||
|
const formatAmount = (value: number) => value.toFixed(2);
|
||||||
|
|
||||||
|
const sanitizeText = (value: string | null | undefined, maxLength = 140) => {
|
||||||
|
return (value || "")
|
||||||
|
.replace(/[\n\r;]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, maxLength);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDecryptedEntityBankAccount = (row: typeof entitybankaccounts.$inferSelect) => ({
|
||||||
|
iban: cleanIban(decrypt(row.ibanEncrypted as any)),
|
||||||
|
bic: cleanBic(decrypt(row.bicEncrypted as any)),
|
||||||
|
bankName: decrypt(row.bankNameEncrypted as any),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildDirectDebitTransaction = (item: any) => {
|
||||||
|
const amount = getCreatedDocumentTotal(item.document);
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw new Error(`Beleg ${item.document.documentNumber || item.document.id} hat keinen positiven Zahlungsbetrag.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debtorBankAccount = getDecryptedEntityBankAccount(item.debtorBankAccount);
|
||||||
|
if (!debtorBankAccount.iban || !debtorBankAccount.bic) {
|
||||||
|
throw new Error(`Bankverbindung für Mandat ${item.mandate.reference} ist unvollständig.`);
|
||||||
|
}
|
||||||
|
|
||||||
let doc = xmlbuilder.create(obj, {encoding: 'UTF-8', standalone: true})
|
if (!item.mandate.signedAt) {
|
||||||
|
throw new Error(`Mandat ${item.mandate.reference} hat kein Unterschriftsdatum.`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(doc.end({pretty:true}))
|
return {
|
||||||
|
amount,
|
||||||
|
xml: {
|
||||||
|
PmtId: {
|
||||||
|
EndToEndId: sanitizeText(item.document.documentNumber || `Beleg-${item.document.id}`, 35),
|
||||||
|
},
|
||||||
|
InstdAmt: {
|
||||||
|
"@Ccy": "EUR",
|
||||||
|
"#text": formatAmount(amount),
|
||||||
|
},
|
||||||
|
DrctDbtTx: {
|
||||||
|
MndtRltdInf: {
|
||||||
|
MndtId: sanitizeText(item.mandate.reference, 35),
|
||||||
|
DtOfSgntr: dayjs(item.mandate.signedAt).format("YYYY-MM-DD"),
|
||||||
|
AmdmntInd: "false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DbtrAgt: {
|
||||||
|
FinInstnId: {
|
||||||
|
BIC: debtorBankAccount.bic,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Dbtr: {
|
||||||
|
Nm: sanitizeText(item.customer.name, 70),
|
||||||
|
},
|
||||||
|
DbtrAcct: {
|
||||||
|
Id: {
|
||||||
|
IBAN: debtorBankAccount.iban,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RmtInf: {
|
||||||
|
Ustrd: sanitizeText(`Rechnung ${item.document.documentNumber || item.document.id}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
}
|
export const createSEPAExport = async (
|
||||||
|
server: any,
|
||||||
|
idsToExport: number[],
|
||||||
|
tenantId: number,
|
||||||
|
creditorBankaccountId: number
|
||||||
|
) => {
|
||||||
|
if (!idsToExport.length) {
|
||||||
|
throw new Error("Es wurden keine Belege für den SEPA-Export ausgewählt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tenantData] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(tenants)
|
||||||
|
.where(eq(tenants.id, tenantId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!tenantData?.creditorId) {
|
||||||
|
throw new Error("Für den Mandanten ist keine Gläubiger-ID hinterlegt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [creditorBankAccount] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(bankaccounts)
|
||||||
|
.where(and(
|
||||||
|
eq(bankaccounts.id, creditorBankaccountId),
|
||||||
|
eq(bankaccounts.tenant, tenantId)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!creditorBankAccount) {
|
||||||
|
throw new Error("Das ausgewählte Gläubigerkonto wurde nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await server.db
|
||||||
|
.select({
|
||||||
|
document: createddocuments,
|
||||||
|
customer: customers,
|
||||||
|
mandate: outgoingsepamandates,
|
||||||
|
debtorBankAccount: entitybankaccounts,
|
||||||
|
})
|
||||||
|
.from(createddocuments)
|
||||||
|
.innerJoin(customers, eq(createddocuments.customer, customers.id))
|
||||||
|
.innerJoin(outgoingsepamandates, eq(createddocuments.outgoingsepamandate, outgoingsepamandates.id))
|
||||||
|
.innerJoin(entitybankaccounts, eq(outgoingsepamandates.bankaccount, entitybankaccounts.id))
|
||||||
|
.where(and(
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
eq(createddocuments.payment_type, "direct-debit"),
|
||||||
|
inArray(createddocuments.id, idsToExport)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (rows.length !== idsToExport.length) {
|
||||||
|
throw new Error("Nicht alle ausgewählten Belege sind gültige SEPA-Lastschrift-Belege mit Mandat.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidMandate = rows.find((row) => row.mandate.tenant !== tenantId || row.mandate.status !== "Aktiv" || row.mandate.archived);
|
||||||
|
if (invalidMandate) {
|
||||||
|
throw new Error(`Mandat ${invalidMandate.mandate.reference} ist nicht aktiv oder gehört nicht zum Mandanten.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
transaction: buildDirectDebitTransaction(row),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalAmount = Number(transactions.reduce((sum, item) => sum + item.transaction.amount, 0).toFixed(2));
|
||||||
|
const messageId = randomUUID();
|
||||||
|
const collectionDate = dayjs().add(3, "days").format("YYYY-MM-DD");
|
||||||
|
const createdAt = dayjs().format("YYYY-MM-DDTHH:mm:ss");
|
||||||
|
const creditorIban = cleanIban(creditorBankAccount.iban);
|
||||||
|
const creditorBic = cleanBic(creditorBankAccount.bankId);
|
||||||
|
|
||||||
|
type SepaTransaction = (typeof transactions)[number];
|
||||||
|
const groupedTransactions: Record<string, SepaTransaction[]> = {};
|
||||||
|
|
||||||
|
transactions.forEach((item) => {
|
||||||
|
const key = `${item.mandate.mandateType || "CORE"}-${item.mandate.sequenceType || "RCUR"}`;
|
||||||
|
groupedTransactions[key] = groupedTransactions[key] || [];
|
||||||
|
groupedTransactions[key].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentInformations = Object.entries(groupedTransactions).map(([key, items], index) => {
|
||||||
|
const groupTotal = Number(items.reduce((sum, item) => sum + item.transaction.amount, 0).toFixed(2));
|
||||||
|
const [mandateType, sequenceType] = key.split("-");
|
||||||
|
|
||||||
|
return {
|
||||||
|
PmtInfId: sanitizeText(`${messageId}-${index + 1}`, 35),
|
||||||
|
PmtMtd: "DD",
|
||||||
|
BtchBookg: "true",
|
||||||
|
NbOfTxs: items.length,
|
||||||
|
CtrlSum: formatAmount(groupTotal),
|
||||||
|
PmtTpInf: {
|
||||||
|
SvcLvl: {
|
||||||
|
Cd: "SEPA",
|
||||||
|
},
|
||||||
|
LclInstrm: {
|
||||||
|
Cd: mandateType,
|
||||||
|
},
|
||||||
|
SeqTp: sequenceType,
|
||||||
|
},
|
||||||
|
ReqdColltnDt: collectionDate,
|
||||||
|
Cdtr: {
|
||||||
|
Nm: sanitizeText(tenantData.name, 70),
|
||||||
|
},
|
||||||
|
CdtrAcct: {
|
||||||
|
Id: {
|
||||||
|
IBAN: creditorIban,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CdtrAgt: {
|
||||||
|
FinInstnId: {
|
||||||
|
BIC: creditorBic,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ChrgBr: "SLEV",
|
||||||
|
CdtrSchmeId: {
|
||||||
|
Id: {
|
||||||
|
PrvtId: {
|
||||||
|
Othr: {
|
||||||
|
Id: tenantData.creditorId,
|
||||||
|
SchmeNm: {
|
||||||
|
Prtry: "SEPA",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DrctDbtTxInf: items.map((item) => item.transaction.xml),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const obj = {
|
||||||
|
Document: {
|
||||||
|
"@xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
|
||||||
|
CstmrDrctDbtInitn: {
|
||||||
|
GrpHdr: {
|
||||||
|
MsgId: sanitizeText(messageId, 35),
|
||||||
|
CreDtTm: createdAt,
|
||||||
|
NbOfTxs: transactions.length,
|
||||||
|
CtrlSum: formatAmount(totalAmount),
|
||||||
|
InitgPty: {
|
||||||
|
Nm: sanitizeText(tenantData.name, 70),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PmtInf: paymentInformations,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const xml = xmlbuilder.create(obj, { encoding: "UTF-8", standalone: true }).end({ pretty: true });
|
||||||
|
const documentDates = rows.map((row) => dayjs(row.document.documentDate)).filter((date) => date.isValid());
|
||||||
|
const startDate = documentDates.reduce((min, date) => date.isBefore(min) ? date : min, documentDates[0] || dayjs());
|
||||||
|
const endDate = documentDates.reduce((max, date) => date.isAfter(max) ? date : max, documentDates[0] || dayjs());
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(xml, "utf-8"),
|
||||||
|
startDate: startDate.toDate(),
|
||||||
|
endDate: endDate.toDate(),
|
||||||
|
count: transactions.length,
|
||||||
|
totalAmount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export const useNextNumberRangeNumber = async (
|
|||||||
tenantId: number,
|
tenantId: number,
|
||||||
numberRange: string
|
numberRange: string
|
||||||
) => {
|
) => {
|
||||||
|
const numberRangeFallbacks: Record<string, string> = {
|
||||||
|
costEstimates: "quotes",
|
||||||
|
packingSlips: "deliveryNotes",
|
||||||
|
advanceInvoices: "invoices",
|
||||||
|
cancellationInvoices: "invoices",
|
||||||
|
}
|
||||||
|
|
||||||
const [tenant] = await server.db
|
const [tenant] = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(tenants)
|
.from(tenants)
|
||||||
@@ -23,11 +30,15 @@ export const useNextNumberRangeNumber = async (
|
|||||||
|
|
||||||
const numberRanges = tenant.numberRanges || {}
|
const numberRanges = tenant.numberRanges || {}
|
||||||
|
|
||||||
if (!numberRanges[numberRange]) {
|
const resolvedNumberRange = numberRanges[numberRange]
|
||||||
|
? numberRange
|
||||||
|
: numberRangeFallbacks[numberRange]
|
||||||
|
|
||||||
|
if (!resolvedNumberRange || !numberRanges[resolvedNumberRange]) {
|
||||||
throw new Error(`Number range '${numberRange}' not found`)
|
throw new Error(`Number range '${numberRange}' not found`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = numberRanges[numberRange]
|
const current = numberRanges[resolvedNumberRange]
|
||||||
|
|
||||||
const usedNumber =
|
const usedNumber =
|
||||||
(current.prefix || "") +
|
(current.prefix || "") +
|
||||||
@@ -37,7 +48,7 @@ export const useNextNumberRangeNumber = async (
|
|||||||
const updatedRanges = {
|
const updatedRanges = {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...numberRanges,
|
...numberRanges,
|
||||||
[numberRange]: {
|
[resolvedNumberRange]: {
|
||||||
...current,
|
...current,
|
||||||
nextNumber: current.nextNumber + 1,
|
nextNumber: current.nextNumber + 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema";
|
|||||||
|
|
||||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||||
customers: "Kunden",
|
customers: "Kunden",
|
||||||
|
contracts: "Verträge",
|
||||||
members: "Mitglieder",
|
members: "Mitglieder",
|
||||||
vendors: "Lieferanten",
|
vendors: "Lieferanten",
|
||||||
projects: "Projekte",
|
projects: "Projekte",
|
||||||
@@ -32,6 +33,8 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
|||||||
incominginvoices: "Eingangsrechnungen",
|
incominginvoices: "Eingangsrechnungen",
|
||||||
files: "Dateien",
|
files: "Dateien",
|
||||||
memberrelations: "Mitgliedsverhältnisse",
|
memberrelations: "Mitgliedsverhältnisse",
|
||||||
|
teams: "Teams",
|
||||||
|
outgoingsepamandates: "Ausgehende SEPA-Mandate",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHistoryEntityLabel(entity: string) {
|
export function getHistoryEntityLabel(entity: string) {
|
||||||
@@ -62,6 +65,7 @@ export async function insertHistoryItem(
|
|||||||
|
|
||||||
const columnMap: Record<string, string> = {
|
const columnMap: Record<string, string> = {
|
||||||
customers: "customer",
|
customers: "customer",
|
||||||
|
contracts: "contract",
|
||||||
members: "customer",
|
members: "customer",
|
||||||
vendors: "vendor",
|
vendors: "vendor",
|
||||||
projects: "project",
|
projects: "project",
|
||||||
@@ -91,6 +95,7 @@ export async function insertHistoryItem(
|
|||||||
incominginvoices: "incomingInvoice",
|
incominginvoices: "incomingInvoice",
|
||||||
files: "file",
|
files: "file",
|
||||||
memberrelations: "memberrelation",
|
memberrelations: "memberrelation",
|
||||||
|
outgoingsepamandates: "outgoingsepamandate",
|
||||||
}
|
}
|
||||||
|
|
||||||
const fkColumn = columnMap[params.entity]
|
const fkColumn = columnMap[params.entity]
|
||||||
|
|||||||
839
backend/src/utils/liquidityForecast.ts
Normal file
839
backend/src/utils/liquidityForecast.ts
Normal file
@@ -0,0 +1,839 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResponseFormat } from "openai/helpers/zod";
|
||||||
|
import { and, desc, eq, gte } from "drizzle-orm";
|
||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bankaccounts,
|
||||||
|
bankstatements,
|
||||||
|
createddocuments,
|
||||||
|
incominginvoices,
|
||||||
|
statementallocations,
|
||||||
|
tenants,
|
||||||
|
} from "../../db/schema";
|
||||||
|
import { secrets } from "./secrets";
|
||||||
|
|
||||||
|
type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement" | "serial_template";
|
||||||
|
|
||||||
|
type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly";
|
||||||
|
|
||||||
|
type ForecastEvent = {
|
||||||
|
date: string;
|
||||||
|
amount: number;
|
||||||
|
label: string;
|
||||||
|
source: ForecastEventSource;
|
||||||
|
sourceId?: number | string | null;
|
||||||
|
recurringKey?: string;
|
||||||
|
confidence?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaxBreakdown = {
|
||||||
|
net19: number;
|
||||||
|
tax19: number;
|
||||||
|
net7: number;
|
||||||
|
tax7: number;
|
||||||
|
net0: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaxForecastPeriod = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
range: string;
|
||||||
|
dueDate: string;
|
||||||
|
outputTax: number;
|
||||||
|
inputTax: number;
|
||||||
|
balance: number;
|
||||||
|
outputCount: number;
|
||||||
|
inputCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecurringCandidate = {
|
||||||
|
key?: string;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
interval: "weekly" | "monthly" | "quarterly" | "yearly";
|
||||||
|
nextDate: string;
|
||||||
|
confidence: number;
|
||||||
|
evidence: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AiRecurringFormat = z.object({
|
||||||
|
candidates: z.array(z.object({
|
||||||
|
label: z.string(),
|
||||||
|
amount: z.number(),
|
||||||
|
interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]),
|
||||||
|
nextDate: z.string(),
|
||||||
|
confidence: z.number().min(0).max(1),
|
||||||
|
evidence: z.string(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FORECAST_DAYS = 90;
|
||||||
|
const HISTORY_MONTHS = 12;
|
||||||
|
|
||||||
|
const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2));
|
||||||
|
const createZeroTaxBreakdown = (): TaxBreakdown => ({
|
||||||
|
net19: 0,
|
||||||
|
tax19: 0,
|
||||||
|
net7: 0,
|
||||||
|
tax7: 0,
|
||||||
|
net0: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeText = (value: unknown) => String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9äöüß]+/gi, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const normalizeTaxEvaluationPeriod = (value?: string | null): TaxEvaluationPeriod => {
|
||||||
|
if (value === "quarterly" || value === "yearly") return value;
|
||||||
|
return "monthly";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTaxFreeDocument = (taxType?: string | null) => {
|
||||||
|
return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || ""));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTaxEvaluationPeriodBounds = (
|
||||||
|
referenceDate: dayjs.ConfigType,
|
||||||
|
period: TaxEvaluationPeriod
|
||||||
|
) => {
|
||||||
|
const base = dayjs(referenceDate);
|
||||||
|
|
||||||
|
if (period === "yearly") {
|
||||||
|
return {
|
||||||
|
start: base.startOf("year"),
|
||||||
|
end: base.endOf("year"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === "quarterly") {
|
||||||
|
const quarterStartMonth = Math.floor(base.month() / 3) * 3;
|
||||||
|
const start = base.month(quarterStartMonth).startOf("month");
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end: start.add(2, "month").endOf("month"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: base.startOf("month"),
|
||||||
|
end: base.endOf("month"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const shiftTaxEvaluationPeriodStart = (
|
||||||
|
periodStart: dayjs.ConfigType,
|
||||||
|
period: TaxEvaluationPeriod,
|
||||||
|
offset: number
|
||||||
|
) => {
|
||||||
|
const base = dayjs(periodStart);
|
||||||
|
|
||||||
|
if (period === "yearly") return base.add(offset, "year").startOf("year");
|
||||||
|
if (period === "quarterly") return base.add(offset * 3, "month").startOf("month");
|
||||||
|
return base.add(offset, "month").startOf("month");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTaxEvaluationPeriodLabel = (
|
||||||
|
periodStart: dayjs.ConfigType,
|
||||||
|
period: TaxEvaluationPeriod
|
||||||
|
) => {
|
||||||
|
const { start } = getTaxEvaluationPeriodBounds(periodStart, period);
|
||||||
|
|
||||||
|
if (period === "yearly") {
|
||||||
|
return start.format("YYYY");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === "quarterly") {
|
||||||
|
return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return start.format("MMMM YYYY");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTaxEvaluationPeriodRange = (
|
||||||
|
periodStart: dayjs.ConfigType,
|
||||||
|
period: TaxEvaluationPeriod
|
||||||
|
) => {
|
||||||
|
const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period);
|
||||||
|
return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTaxSettlementDate = (periodEnd: dayjs.Dayjs) => {
|
||||||
|
return periodEnd.add(1, "month").date(10).startOf("day");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecurringKey = (candidate: RecurringCandidate) => {
|
||||||
|
const rawKey = [
|
||||||
|
Math.sign(candidate.amount),
|
||||||
|
Math.round(Math.abs(candidate.amount) * 100),
|
||||||
|
normalizeText(candidate.label),
|
||||||
|
candidate.interval,
|
||||||
|
].join("|");
|
||||||
|
|
||||||
|
return createHash("sha1").update(rawKey).digest("hex").slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
|
||||||
|
if (interval === "weekly") return date.add(1, "week");
|
||||||
|
if (interval === "quarterly") return date.add(3, "month");
|
||||||
|
if (interval === "yearly") return date.add(1, "year");
|
||||||
|
return date.add(1, "month");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSerialInterval = (date: dayjs.Dayjs, interval: string) => {
|
||||||
|
if (interval === "wöchentlich") return date.add(1, "week");
|
||||||
|
if (interval === "2 - wöchentlich") return date.add(2, "week");
|
||||||
|
if (interval === "vierteljährlich") return date.add(3, "month");
|
||||||
|
if (interval === "halbjährlich") return date.add(6, "month");
|
||||||
|
if (interval === "jährlich") return date.add(1, "year");
|
||||||
|
return date.add(1, "month");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatementPartner = (statement: any) => {
|
||||||
|
return statement.amount < 0
|
||||||
|
? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe"
|
||||||
|
: statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => {
|
||||||
|
let totalNet = 0;
|
||||||
|
let totalTax = 0;
|
||||||
|
|
||||||
|
(document.rows || []).forEach((row: any) => {
|
||||||
|
if (["pagebreak", "title", "text"].includes(row.mode)) return;
|
||||||
|
|
||||||
|
const rowNet = Number(
|
||||||
|
(Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)
|
||||||
|
);
|
||||||
|
const taxPercent = Number(row.taxPercent);
|
||||||
|
|
||||||
|
totalNet += rowNet;
|
||||||
|
totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
let advancePayments = 0;
|
||||||
|
(document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => {
|
||||||
|
const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId);
|
||||||
|
const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData);
|
||||||
|
if (!advanceRow) return;
|
||||||
|
|
||||||
|
advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCreatedDocumentTaxBreakdown = (document: any): TaxBreakdown => {
|
||||||
|
const breakdown = createZeroTaxBreakdown();
|
||||||
|
|
||||||
|
if (!document || isTaxFreeDocument(document.taxType)) {
|
||||||
|
return breakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
(document.rows || []).forEach((row: any) => {
|
||||||
|
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return;
|
||||||
|
|
||||||
|
const quantity = Number(row.quantity || 0);
|
||||||
|
const price = Number(row.price || 0);
|
||||||
|
const discountPercent = Number(row.discountPercent || 0);
|
||||||
|
const taxPercent = Number(row.taxPercent || 0);
|
||||||
|
const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2));
|
||||||
|
|
||||||
|
if (!Number.isFinite(net) || net === 0) return;
|
||||||
|
|
||||||
|
if (taxPercent === 19) {
|
||||||
|
breakdown.net19 += net;
|
||||||
|
breakdown.tax19 += Number((net * 0.19).toFixed(2));
|
||||||
|
} else if (taxPercent === 7) {
|
||||||
|
breakdown.net7 += net;
|
||||||
|
breakdown.tax7 += Number((net * 0.07).toFixed(2));
|
||||||
|
} else {
|
||||||
|
breakdown.net0 += net;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
net19: roundMoney(breakdown.net19),
|
||||||
|
tax19: roundMoney(breakdown.tax19),
|
||||||
|
net7: roundMoney(breakdown.net7),
|
||||||
|
tax7: roundMoney(breakdown.tax7),
|
||||||
|
net0: roundMoney(breakdown.net0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIncomingInvoiceTaxBreakdown = (invoice: any): TaxBreakdown => {
|
||||||
|
const breakdown = createZeroTaxBreakdown();
|
||||||
|
|
||||||
|
(invoice?.accounts || []).forEach((account: any) => {
|
||||||
|
const taxType = String(account?.taxType || "");
|
||||||
|
const amountNet = Number(account?.amountNet || 0);
|
||||||
|
const amountTax = Number(account?.amountTax || 0);
|
||||||
|
|
||||||
|
if (taxType === "19") {
|
||||||
|
breakdown.net19 += amountNet;
|
||||||
|
breakdown.tax19 += amountTax;
|
||||||
|
} else if (taxType === "7") {
|
||||||
|
breakdown.net7 += amountNet;
|
||||||
|
breakdown.tax7 += amountTax;
|
||||||
|
} else {
|
||||||
|
breakdown.net0 += amountNet;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
net19: roundMoney(breakdown.net19),
|
||||||
|
tax19: roundMoney(breakdown.tax19),
|
||||||
|
net7: roundMoney(breakdown.net7),
|
||||||
|
tax7: roundMoney(breakdown.tax7),
|
||||||
|
net0: roundMoney(breakdown.net0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIncomingInvoiceSignedAmount = (invoice: any) => {
|
||||||
|
const amount = (invoice.accounts || []).reduce((sum: number, account: any) => {
|
||||||
|
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return roundMoney(invoice.expense === false ? amount : amount * -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRemainingSignedAmount = (signedAmount: number, allocatedAmount: number) => {
|
||||||
|
const remainingAbsolute = Math.max(0, Math.abs(Number(signedAmount || 0)) - Math.abs(Number(allocatedAmount || 0)));
|
||||||
|
if (remainingAbsolute <= 0.01) return 0;
|
||||||
|
return roundMoney(Math.sign(Number(signedAmount || 0)) * remainingAbsolute);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findCancellationDocumentIds = (documents: any[]) => {
|
||||||
|
return new Set(
|
||||||
|
documents
|
||||||
|
.filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived)
|
||||||
|
.map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument)
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => {
|
||||||
|
if (dates.length < 3) return null;
|
||||||
|
|
||||||
|
const gaps = dates
|
||||||
|
.slice(1)
|
||||||
|
.map((date, index) => date.diff(dates[index], "day"))
|
||||||
|
.filter((gap) => gap > 0);
|
||||||
|
|
||||||
|
const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1);
|
||||||
|
|
||||||
|
if (averageGap >= 6 && averageGap <= 8) return "weekly";
|
||||||
|
if (averageGap >= 25 && averageGap <= 35) return "monthly";
|
||||||
|
if (averageGap >= 80 && averageGap <= 100) return "quarterly";
|
||||||
|
if (averageGap >= 340 && averageGap <= 390) return "yearly";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => {
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
|
||||||
|
statements.forEach((statement) => {
|
||||||
|
const parsedDate = dayjs(statement.valueDate || statement.date);
|
||||||
|
if (!parsedDate.isValid() || Number(statement.amount || 0) >= 0) return;
|
||||||
|
|
||||||
|
const partner = normalizeText(getStatementPartner(statement));
|
||||||
|
const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" ");
|
||||||
|
const amountBucket = Math.round(Number(statement.amount || 0) * 100);
|
||||||
|
const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|");
|
||||||
|
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(statement);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
.map((group) => {
|
||||||
|
const sorted = group
|
||||||
|
.map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) }))
|
||||||
|
.sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf());
|
||||||
|
|
||||||
|
const interval = inferInterval(sorted.map((statement) => statement.parsedDate));
|
||||||
|
if (!interval) return null;
|
||||||
|
|
||||||
|
let next = addInterval(sorted[sorted.length - 1].parsedDate, interval);
|
||||||
|
while (next.isBefore(dayjs(), "day")) {
|
||||||
|
next = addInterval(next, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length);
|
||||||
|
const partner = getStatementPartner(sorted[sorted.length - 1]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: partner,
|
||||||
|
amount,
|
||||||
|
interval,
|
||||||
|
nextDate: next.format("YYYY-MM-DD"),
|
||||||
|
confidence: Math.min(0.9, 0.55 + sorted.length * 0.08),
|
||||||
|
evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as RecurringCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
|
||||||
|
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
|
||||||
|
|
||||||
|
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
|
||||||
|
const compactStatements = statements.slice(0, 220).map((statement) => ({
|
||||||
|
date: statement.valueDate || statement.date,
|
||||||
|
amount: roundMoney(Number(statement.amount || 0)),
|
||||||
|
partner: getStatementPartner(statement),
|
||||||
|
text: String(statement.text || "").slice(0, 160),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completion = await openai.chat.completions.parse({
|
||||||
|
model: "gpt-4o",
|
||||||
|
store: true,
|
||||||
|
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: JSON.stringify({
|
||||||
|
today: dayjs().format("YYYY-MM-DD"),
|
||||||
|
horizonDays: FORECAST_DAYS,
|
||||||
|
bankStatements: compactStatements,
|
||||||
|
rules: [
|
||||||
|
"amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.",
|
||||||
|
"nextDate muss in der Zukunft liegen.",
|
||||||
|
"Nutze keine einmaligen oder unsicheren Bewegungen.",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (completion.choices[0].message.parsed?.candidates || [])
|
||||||
|
.filter((candidate) => dayjs(candidate.nextDate).isValid())
|
||||||
|
.filter((candidate) => Number(candidate.amount || 0) < 0)
|
||||||
|
.map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
amount: roundMoney(candidate.amount),
|
||||||
|
confidence: Math.max(0, Math.min(1, candidate.confidence)),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden.");
|
||||||
|
server.log.warn(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => {
|
||||||
|
const merged = new Map<string, RecurringCandidate>();
|
||||||
|
|
||||||
|
[...heuristic, ...ai].forEach((candidate) => {
|
||||||
|
const key = [
|
||||||
|
Math.sign(candidate.amount),
|
||||||
|
Math.round(Math.abs(candidate.amount) * 100),
|
||||||
|
normalizeText(candidate.label).slice(0, 32),
|
||||||
|
candidate.interval,
|
||||||
|
].join("|");
|
||||||
|
const existing = merged.get(key);
|
||||||
|
|
||||||
|
if (!existing || candidate.confidence > existing.confidence) {
|
||||||
|
merged.set(key, candidate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...merged.values()]
|
||||||
|
.map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
key: getRecurringKey(candidate),
|
||||||
|
}))
|
||||||
|
.filter((candidate) => Number(candidate.amount || 0) < 0)
|
||||||
|
.filter((candidate) => Math.abs(candidate.amount) >= 1)
|
||||||
|
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => {
|
||||||
|
const events: ForecastEvent[] = [];
|
||||||
|
|
||||||
|
recurring.forEach((candidate) => {
|
||||||
|
let date = dayjs(candidate.nextDate);
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) {
|
||||||
|
events.push({
|
||||||
|
date: date.format("YYYY-MM-DD"),
|
||||||
|
amount: roundMoney(candidate.amount),
|
||||||
|
label: candidate.label,
|
||||||
|
source: "recurring_bankstatement",
|
||||||
|
recurringKey: candidate.key,
|
||||||
|
confidence: candidate.confidence,
|
||||||
|
});
|
||||||
|
date = addInterval(date, candidate.interval);
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTaxForecastPeriods = (
|
||||||
|
documents: any[],
|
||||||
|
incomingInvoices: any[],
|
||||||
|
periodType: TaxEvaluationPeriod,
|
||||||
|
today: dayjs.Dayjs,
|
||||||
|
endDate: dayjs.Dayjs
|
||||||
|
) => {
|
||||||
|
const currentBounds = getTaxEvaluationPeriodBounds(today, periodType);
|
||||||
|
const periods: TaxForecastPeriod[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset < 12) {
|
||||||
|
const periodStart = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType, offset);
|
||||||
|
const bounds = getTaxEvaluationPeriodBounds(periodStart, periodType);
|
||||||
|
const dueDate = getTaxSettlementDate(bounds.end);
|
||||||
|
|
||||||
|
if (dueDate.isAfter(endDate, "day")) break;
|
||||||
|
|
||||||
|
const outputDocs = documents.filter((document) => {
|
||||||
|
if (document?.state !== "Gebucht") return false;
|
||||||
|
if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(document?.type)) return false;
|
||||||
|
|
||||||
|
const date = dayjs(document.documentDate);
|
||||||
|
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputDocs = incomingInvoices.filter((invoice) => {
|
||||||
|
if (invoice?.state !== "Gebucht" || !invoice?.date) return false;
|
||||||
|
|
||||||
|
const date = dayjs(invoice.date);
|
||||||
|
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputTax = roundMoney(outputDocs.reduce((sum, document) => {
|
||||||
|
const breakdown = getCreatedDocumentTaxBreakdown(document);
|
||||||
|
return sum + breakdown.tax19 + breakdown.tax7;
|
||||||
|
}, 0));
|
||||||
|
|
||||||
|
const inputTax = roundMoney(inputDocs.reduce((sum, invoice) => {
|
||||||
|
const breakdown = getIncomingInvoiceTaxBreakdown(invoice);
|
||||||
|
return sum + breakdown.tax19 + breakdown.tax7;
|
||||||
|
}, 0));
|
||||||
|
|
||||||
|
const balance = roundMoney(outputTax - inputTax);
|
||||||
|
|
||||||
|
periods.push({
|
||||||
|
key: bounds.start.format("YYYY-MM-DD"),
|
||||||
|
label: formatTaxEvaluationPeriodLabel(bounds.start, periodType),
|
||||||
|
range: formatTaxEvaluationPeriodRange(bounds.start, periodType),
|
||||||
|
dueDate: dueDate.format("YYYY-MM-DD"),
|
||||||
|
outputTax,
|
||||||
|
inputTax,
|
||||||
|
balance,
|
||||||
|
outputCount: outputDocs.length,
|
||||||
|
inputCount: inputDocs.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
offset += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return periods;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSerialTemplateEvents = (
|
||||||
|
documents: any[],
|
||||||
|
today: dayjs.Dayjs,
|
||||||
|
endDate: dayjs.Dayjs
|
||||||
|
) => {
|
||||||
|
const events: ForecastEvent[] = [];
|
||||||
|
|
||||||
|
documents
|
||||||
|
.filter((document) => document.type === "serialInvoices")
|
||||||
|
.filter((document) => document.serialConfig?.active)
|
||||||
|
.forEach((document) => {
|
||||||
|
const firstExecution = dayjs(document.serialConfig?.firstExecution);
|
||||||
|
const executionUntil = dayjs(document.serialConfig?.executionUntil);
|
||||||
|
|
||||||
|
if (!firstExecution.isValid() || !executionUntil.isValid()) return;
|
||||||
|
|
||||||
|
const amount = getCreatedDocumentGrossAmount(document, documents);
|
||||||
|
if (amount <= 0.01) return;
|
||||||
|
|
||||||
|
let executionDate = firstExecution.startOf("day");
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (executionDate.isBefore(today, "day") && guard < 240) {
|
||||||
|
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (
|
||||||
|
executionDate.isValid()
|
||||||
|
&& !executionDate.isAfter(executionUntil, "day")
|
||||||
|
&& !executionDate.isAfter(endDate, "day")
|
||||||
|
&& guard < 400
|
||||||
|
) {
|
||||||
|
const dueDate = executionDate.add(Number(document.paymentDays || 0), "day");
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
|
||||||
|
amount,
|
||||||
|
label: document.documentNumber || document.title || `Serienvorlage ${document.id}`,
|
||||||
|
source: "serial_template",
|
||||||
|
sourceId: document.id,
|
||||||
|
confidence: 0.75,
|
||||||
|
});
|
||||||
|
|
||||||
|
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return events.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateLiquidityForecast = async (
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
ignoredRecurringKeys: string[] = []
|
||||||
|
) => {
|
||||||
|
const today = dayjs().startOf("day");
|
||||||
|
const endDate = today.add(FORECAST_DAYS, "day");
|
||||||
|
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
|
||||||
|
|
||||||
|
const [accounts, statements, documents, incomingInvoices, allocations, tenantSettings] = await Promise.all([
|
||||||
|
server.db
|
||||||
|
.select()
|
||||||
|
.from(bankaccounts)
|
||||||
|
.where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
|
||||||
|
server.db
|
||||||
|
.select()
|
||||||
|
.from(bankstatements)
|
||||||
|
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart)))
|
||||||
|
.orderBy(desc(bankstatements.valueDate)),
|
||||||
|
server.db
|
||||||
|
.select()
|
||||||
|
.from(createddocuments)
|
||||||
|
.where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))),
|
||||||
|
server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))),
|
||||||
|
server.db
|
||||||
|
.select()
|
||||||
|
.from(statementallocations)
|
||||||
|
.where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))),
|
||||||
|
server.db
|
||||||
|
.select({
|
||||||
|
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
|
||||||
|
})
|
||||||
|
.from(tenants)
|
||||||
|
.where(eq(tenants.id, tenantId))
|
||||||
|
.limit(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeAccounts = accounts.filter((account) => !account.archived);
|
||||||
|
const activeStatements = statements.filter((statement) => !statement.archived);
|
||||||
|
const activeDocuments = documents.filter((document) => !document.archived);
|
||||||
|
const activeIncomingInvoices = incomingInvoices.filter((invoice) => !invoice.archived);
|
||||||
|
const activeDocumentIds = new Set(activeDocuments.map((document) => document.id));
|
||||||
|
const activeIncomingInvoiceIds = new Set(activeIncomingInvoices.map((invoice) => invoice.id));
|
||||||
|
const taxPeriodType = normalizeTaxEvaluationPeriod(tenantSettings[0]?.taxEvaluationPeriod);
|
||||||
|
const activeAllocations = allocations.filter((allocation) => {
|
||||||
|
if (allocation.archived) return false;
|
||||||
|
if (allocation.createddocument && !activeDocumentIds.has(allocation.createddocument)) return false;
|
||||||
|
if (allocation.incominginvoice && !activeIncomingInvoiceIds.has(allocation.incominginvoice)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startingBalance = roundMoney(
|
||||||
|
activeAccounts
|
||||||
|
.filter((account) => !account.expired)
|
||||||
|
.reduce((sum, account) => sum + Number(account.balance || 0), 0)
|
||||||
|
);
|
||||||
|
const allocationByDocument = new Map<number, number>();
|
||||||
|
const allocationByIncomingInvoice = new Map<number, number>();
|
||||||
|
|
||||||
|
activeAllocations.forEach((allocation) => {
|
||||||
|
if (allocation.createddocument) {
|
||||||
|
allocationByDocument.set(
|
||||||
|
allocation.createddocument,
|
||||||
|
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allocation.incominginvoice) {
|
||||||
|
allocationByIncomingInvoice.set(
|
||||||
|
allocation.incominginvoice,
|
||||||
|
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelledDocumentIds = findCancellationDocumentIds(activeDocuments);
|
||||||
|
const openEvents: ForecastEvent[] = [];
|
||||||
|
const draftEvents: ForecastEvent[] = [];
|
||||||
|
|
||||||
|
activeDocuments
|
||||||
|
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
|
||||||
|
.filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id))
|
||||||
|
.forEach((document) => {
|
||||||
|
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
|
||||||
|
const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0));
|
||||||
|
if (openAmount <= 0.01) return;
|
||||||
|
|
||||||
|
const dueDate = dayjs(document.documentDate).isValid()
|
||||||
|
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
|
||||||
|
: today;
|
||||||
|
|
||||||
|
openEvents.push({
|
||||||
|
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
|
||||||
|
amount: openAmount,
|
||||||
|
label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`,
|
||||||
|
source: "open_createddocument",
|
||||||
|
sourceId: document.id,
|
||||||
|
confidence: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
activeDocuments
|
||||||
|
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
|
||||||
|
.filter((document) => document.state === "Entwurf" && !cancelledDocumentIds.has(document.id))
|
||||||
|
.forEach((document) => {
|
||||||
|
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
|
||||||
|
if (total <= 0.01) return;
|
||||||
|
|
||||||
|
const dueDate = dayjs(document.documentDate).isValid()
|
||||||
|
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
|
||||||
|
: today;
|
||||||
|
|
||||||
|
draftEvents.push({
|
||||||
|
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
|
||||||
|
amount: total,
|
||||||
|
label: document.documentNumber || document.title || `Rechnungsentwurf ${document.id}`,
|
||||||
|
source: "draft_createddocument",
|
||||||
|
sourceId: document.id,
|
||||||
|
confidence: 0.35,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
activeIncomingInvoices
|
||||||
|
.filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet")
|
||||||
|
.filter((invoice) => !invoice.paid)
|
||||||
|
.forEach((invoice) => {
|
||||||
|
const signedAmount = getIncomingInvoiceSignedAmount(invoice);
|
||||||
|
const openAmount = getRemainingSignedAmount(signedAmount, allocationByIncomingInvoice.get(invoice.id) || 0);
|
||||||
|
if (Math.abs(openAmount) <= 0.01) return;
|
||||||
|
|
||||||
|
const dueDate = dayjs(invoice.dueDate || invoice.date).isValid()
|
||||||
|
? dayjs(invoice.dueDate || invoice.date)
|
||||||
|
: today;
|
||||||
|
|
||||||
|
openEvents.push({
|
||||||
|
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
|
||||||
|
amount: openAmount,
|
||||||
|
label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`,
|
||||||
|
source: "open_incominginvoice",
|
||||||
|
sourceId: invoice.id,
|
||||||
|
confidence: invoice.state === "Gebucht" ? 1 : 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const heuristicRecurring = detectRecurringHeuristically(activeStatements);
|
||||||
|
const aiRecurring = await detectRecurringWithAi(server, activeStatements);
|
||||||
|
const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean));
|
||||||
|
const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring)
|
||||||
|
.filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key));
|
||||||
|
const taxPeriods = buildTaxForecastPeriods(activeDocuments, activeIncomingInvoices, taxPeriodType, today, endDate);
|
||||||
|
const taxEvents: ForecastEvent[] = taxPeriods
|
||||||
|
.filter((period) => Math.abs(period.balance) > 0.01)
|
||||||
|
.map((period) => ({
|
||||||
|
date: period.dueDate,
|
||||||
|
amount: roundMoney(period.balance * -1),
|
||||||
|
label: `USt ${period.label}`,
|
||||||
|
source: "tax_settlement" as const,
|
||||||
|
sourceId: period.key,
|
||||||
|
confidence: 0.95,
|
||||||
|
}));
|
||||||
|
const serialTemplateEvents = buildSerialTemplateEvents(activeDocuments, today, endDate);
|
||||||
|
const events = [
|
||||||
|
...openEvents,
|
||||||
|
...taxEvents,
|
||||||
|
...serialTemplateEvents,
|
||||||
|
...expandRecurringEvents(recurring, endDate),
|
||||||
|
].filter((event) => {
|
||||||
|
const date = dayjs(event.date);
|
||||||
|
return date.isValid() && !date.isAfter(endDate, "day");
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyEvents = new Map<string, ForecastEvent[]>();
|
||||||
|
events.forEach((event) => {
|
||||||
|
if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []);
|
||||||
|
dailyEvents.get(event.date)!.push(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
let runningBalance = startingBalance;
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) {
|
||||||
|
const date = today.add(offset, "day").format("YYYY-MM-DD");
|
||||||
|
const dayEvents = dailyEvents.get(date) || [];
|
||||||
|
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
|
||||||
|
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
|
||||||
|
|
||||||
|
runningBalance = roundMoney(runningBalance + income + expense);
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
date,
|
||||||
|
balance: runningBalance,
|
||||||
|
income,
|
||||||
|
expense,
|
||||||
|
events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]);
|
||||||
|
const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
|
||||||
|
const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
|
||||||
|
const draftIncome = roundMoney(draftEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
horizonDays: FORECAST_DAYS,
|
||||||
|
startingBalance,
|
||||||
|
endingBalance: points[points.length - 1]?.balance || startingBalance,
|
||||||
|
lowestBalance: lowestPoint?.balance || startingBalance,
|
||||||
|
lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"),
|
||||||
|
totalIncome,
|
||||||
|
totalExpense,
|
||||||
|
accounts: activeAccounts.map((account) => ({
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
iban: account.iban,
|
||||||
|
balance: roundMoney(Number(account.balance || 0)),
|
||||||
|
expired: account.expired,
|
||||||
|
syncedAt: account.syncedAt,
|
||||||
|
})),
|
||||||
|
recurring,
|
||||||
|
events: events.sort((a, b) => a.date.localeCompare(b.date)),
|
||||||
|
draftEvents: draftEvents.sort((a, b) => a.date.localeCompare(b.date)),
|
||||||
|
draftIncome,
|
||||||
|
tax: {
|
||||||
|
periodType: taxPeriodType,
|
||||||
|
periods: taxPeriods,
|
||||||
|
totalBalance: roundMoney(taxPeriods.reduce((sum, period) => sum + period.balance, 0)),
|
||||||
|
},
|
||||||
|
points,
|
||||||
|
ai: {
|
||||||
|
enabled: Boolean(secrets.OPENAI_API_KEY),
|
||||||
|
candidates: aiRecurring.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -58,6 +58,8 @@ const getDuration = (time) => {
|
|||||||
|
|
||||||
|
|
||||||
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
|
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
|
||||||
|
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
|
const isPackingSlip = invoiceData?.type === "packingSlips"
|
||||||
|
|
||||||
const genPDF = async (invoiceData, backgroundSourceBuffer) => {
|
const genPDF = async (invoiceData, backgroundSourceBuffer) => {
|
||||||
const pdfDoc = await PDFDocument.create()
|
const pdfDoc = await PDFDocument.create()
|
||||||
@@ -347,7 +349,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold
|
font: fontBold
|
||||||
})
|
})
|
||||||
|
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (isPackingSlip) {
|
||||||
|
pages[pageCounter - 1].drawText("Check", {
|
||||||
|
...getCoordinatesForPDFLib(180, 137, page1),
|
||||||
|
size: 12,
|
||||||
|
color: rgb(0, 0, 0),
|
||||||
|
lineHeight: 12,
|
||||||
|
opacity: 1,
|
||||||
|
maxWidth: 240,
|
||||||
|
font: fontBold
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawText("Steuer", {
|
pages[pageCounter - 1].drawText("Steuer", {
|
||||||
...getCoordinatesForPDFLib(135, 137, page1),
|
...getCoordinatesForPDFLib(135, 137, page1),
|
||||||
size: 12,
|
size: 12,
|
||||||
@@ -414,9 +428,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
maxWidth: 240
|
maxWidth: 240
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isPackingSlip) {
|
||||||
|
pages[pageCounter - 1].drawRectangle({
|
||||||
|
...getCoordinatesForPDFLib(182, rowHeight + 1, page1),
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderColor: rgb(0, 0, 0),
|
||||||
|
borderWidth: 0.8,
|
||||||
|
opacity: 1,
|
||||||
|
borderOpacity: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let rowTextLines = 0
|
let rowTextLines = 0
|
||||||
|
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
|
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
|
||||||
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -428,7 +454,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
rowTextLines = splitStringBySpace(row.text, 35).length
|
rowTextLines = splitStringBySpace(row.text, 35).length
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 80).join("\n"), {
|
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, isPackingSlip ? 68 : 80).join("\n"), {
|
||||||
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
||||||
size: 10,
|
size: 10,
|
||||||
color: rgb(0, 0, 0),
|
color: rgb(0, 0, 0),
|
||||||
@@ -437,13 +463,13 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold
|
font: fontBold
|
||||||
})
|
})
|
||||||
|
|
||||||
rowTextLines = splitStringBySpace(row.text, 80).length
|
rowTextLines = splitStringBySpace(row.text, isPackingSlip ? 68 : 80).length
|
||||||
}
|
}
|
||||||
|
|
||||||
let rowDescriptionLines = 0
|
let rowDescriptionLines = 0
|
||||||
|
|
||||||
if (row.descriptionText) {
|
if (row.descriptionText) {
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
|
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
|
||||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
|
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
|
||||||
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
||||||
@@ -454,8 +480,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
})
|
})
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, 80).length
|
rowDescriptionLines = splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).length
|
||||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 80).join("\n"), {
|
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).join("\n"), {
|
||||||
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
||||||
size: 10,
|
size: 10,
|
||||||
color: rgb(0, 0, 0),
|
color: rgb(0, 0, 0),
|
||||||
@@ -466,7 +492,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawText(`${row.taxPercent} %`, {
|
pages[pageCounter - 1].drawText(`${row.taxPercent} %`, {
|
||||||
...getCoordinatesForPDFLib(135, rowHeight, page1),
|
...getCoordinatesForPDFLib(135, rowHeight, page1),
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -632,7 +658,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold
|
font: fontBold
|
||||||
})
|
})
|
||||||
|
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (isPackingSlip) {
|
||||||
|
page.drawText("Check", {
|
||||||
|
...getCoordinatesForPDFLib(180, 22, page1),
|
||||||
|
size: 12,
|
||||||
|
color: rgb(0, 0, 0),
|
||||||
|
lineHeight: 12,
|
||||||
|
opacity: 1,
|
||||||
|
maxWidth: 240,
|
||||||
|
font: fontBold
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
page.drawText("Steuer", {
|
page.drawText("Steuer", {
|
||||||
...getCoordinatesForPDFLib(135, 22, page1),
|
...getCoordinatesForPDFLib(135, 22, page1),
|
||||||
size: 12,
|
size: 12,
|
||||||
@@ -742,7 +780,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
|
|
||||||
let endTextDiff = 35
|
let endTextDiff = 35
|
||||||
|
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawLine({
|
pages[pageCounter - 1].drawLine({
|
||||||
start: getCoordinatesForPDFLib(20, rowHeight, page1),
|
start: getCoordinatesForPDFLib(20, rowHeight, page1),
|
||||||
end: getCoordinatesForPDFLib(198, rowHeight, page1),
|
end: getCoordinatesForPDFLib(198, rowHeight, page1),
|
||||||
@@ -864,10 +902,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
maxWidth: 500
|
maxWidth: 500
|
||||||
})
|
})
|
||||||
|
|
||||||
return await pdfDoc.saveAsBase64()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await pdfDoc.saveAsBase64()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath))
|
const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath))
|
||||||
@@ -1138,4 +1176,4 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
|
|||||||
console.log(error)
|
console.log(error)
|
||||||
throw error; // Fehler weiterwerfen, damit er oben ankommt
|
throw error; // Fehler weiterwerfen, damit er oben ankommt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
backend/src/utils/profileBranches.ts
Normal file
142
backend/src/utils/profileBranches.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
|
||||||
|
import { authProfileBranches, authProfiles, branches } from "../../db/schema"
|
||||||
|
|
||||||
|
function normalizeBranchIds(values: any[]): number[] {
|
||||||
|
return [...new Set(
|
||||||
|
values
|
||||||
|
.map((value) => {
|
||||||
|
if (typeof value === "number") return value
|
||||||
|
if (typeof value === "string" && value.trim()) return Number(value)
|
||||||
|
if (value && typeof value === "object" && "id" in value) return Number(value.id)
|
||||||
|
return NaN
|
||||||
|
})
|
||||||
|
.filter((value) => Number.isFinite(value))
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichProfilesWithBranches(server: FastifyInstance, profiles: any[]) {
|
||||||
|
if (!profiles.length) return profiles
|
||||||
|
|
||||||
|
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
|
||||||
|
if (!profileIds.length) return profiles
|
||||||
|
|
||||||
|
const profileBranchRows = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authProfileBranches)
|
||||||
|
.where(inArray(authProfileBranches.profile_id, profileIds))
|
||||||
|
|
||||||
|
const branchIds = [...new Set(profileBranchRows.map((row) => row.branch_id).filter(Boolean))]
|
||||||
|
const branchRows = branchIds.length
|
||||||
|
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
|
||||||
|
const branchIdsByProfile = new Map<string, number[]>()
|
||||||
|
|
||||||
|
for (const row of profileBranchRows) {
|
||||||
|
const current = branchIdsByProfile.get(row.profile_id) || []
|
||||||
|
current.push(row.branch_id)
|
||||||
|
branchIdsByProfile.set(row.profile_id, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles.map((profile) => {
|
||||||
|
const assignedBranchIds = [...new Set(branchIdsByProfile.get(profile.id) || [])]
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
branch: profile.branch_id ? branchMap.get(profile.branch_id) || null : null,
|
||||||
|
branches: assignedBranchIds
|
||||||
|
.map((branchId) => branchMap.get(branchId))
|
||||||
|
.filter(Boolean),
|
||||||
|
branch_ids: assignedBranchIds,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProfileWithBranches(server: FastifyInstance, profileId: string, tenantId: number) {
|
||||||
|
const rows = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(authProfiles.id, profileId),
|
||||||
|
eq(authProfiles.tenant_id, tenantId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!rows.length) return null
|
||||||
|
|
||||||
|
const [profile] = await enrichProfilesWithBranches(server, rows)
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTenantBranchIds(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
values: any[],
|
||||||
|
primaryBranchId?: any
|
||||||
|
) {
|
||||||
|
const normalizedPrimaryBranchId = primaryBranchId == null || primaryBranchId === ""
|
||||||
|
? null
|
||||||
|
: Number(primaryBranchId)
|
||||||
|
|
||||||
|
const requestedBranchIds = normalizeBranchIds([
|
||||||
|
...values,
|
||||||
|
normalizedPrimaryBranchId,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!requestedBranchIds.length) {
|
||||||
|
return {
|
||||||
|
primaryBranchId: normalizedPrimaryBranchId,
|
||||||
|
branchIds: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validBranches = await server.db
|
||||||
|
.select({ id: branches.id })
|
||||||
|
.from(branches)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(branches.tenant, tenantId),
|
||||||
|
inArray(branches.id, requestedBranchIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const validBranchIds = validBranches.map((branch) => branch.id)
|
||||||
|
|
||||||
|
if (validBranchIds.length !== requestedBranchIds.length) {
|
||||||
|
throw new Error("INVALID_BRANCH_SELECTION")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPrimaryBranchId != null && !validBranchIds.includes(normalizedPrimaryBranchId)) {
|
||||||
|
throw new Error("INVALID_PRIMARY_BRANCH")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryBranchId: normalizedPrimaryBranchId,
|
||||||
|
branchIds: validBranchIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncProfileBranches(
|
||||||
|
server: FastifyInstance,
|
||||||
|
profileId: string,
|
||||||
|
branchIds: number[],
|
||||||
|
userId?: string | null
|
||||||
|
) {
|
||||||
|
await server.db
|
||||||
|
.delete(authProfileBranches)
|
||||||
|
.where(eq(authProfileBranches.profile_id, profileId))
|
||||||
|
|
||||||
|
if (!branchIds.length) return
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.insert(authProfileBranches)
|
||||||
|
.values(branchIds.map((branchId) => ({
|
||||||
|
profile_id: profileId,
|
||||||
|
branch_id: branchId,
|
||||||
|
created_by: userId || null,
|
||||||
|
})))
|
||||||
|
}
|
||||||
117
backend/src/utils/profileTeams.ts
Normal file
117
backend/src/utils/profileTeams.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
|
||||||
|
import { authProfileTeams, branches, teams } from "../../db/schema"
|
||||||
|
|
||||||
|
function normalizeTeamIds(values: any[]): number[] {
|
||||||
|
return [...new Set(
|
||||||
|
values
|
||||||
|
.map((value) => {
|
||||||
|
if (typeof value === "number") return value
|
||||||
|
if (typeof value === "string" && value.trim()) return Number(value)
|
||||||
|
if (value && typeof value === "object" && "id" in value) return Number(value.id)
|
||||||
|
return NaN
|
||||||
|
})
|
||||||
|
.filter((value) => Number.isFinite(value))
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichProfilesWithTeams(server: FastifyInstance, profiles: any[]) {
|
||||||
|
if (!profiles.length) return profiles
|
||||||
|
|
||||||
|
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
|
||||||
|
if (!profileIds.length) return profiles
|
||||||
|
|
||||||
|
const profileTeamRows = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authProfileTeams)
|
||||||
|
.where(inArray(authProfileTeams.profile_id, profileIds))
|
||||||
|
|
||||||
|
const teamIds = [...new Set(profileTeamRows.map((row) => row.team_id).filter(Boolean))]
|
||||||
|
const teamRows = teamIds.length
|
||||||
|
? await server.db.select().from(teams).where(inArray(teams.id, teamIds))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const branchIds = [...new Set(teamRows.map((team) => team.branch).filter(Boolean))]
|
||||||
|
const branchRows = branchIds.length
|
||||||
|
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
|
||||||
|
const teamMap = new Map(teamRows.map((team) => [
|
||||||
|
team.id,
|
||||||
|
{
|
||||||
|
...team,
|
||||||
|
branch: team.branch ? branchMap.get(team.branch) || null : null,
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
const teamIdsByProfile = new Map<string, number[]>()
|
||||||
|
|
||||||
|
for (const row of profileTeamRows) {
|
||||||
|
const current = teamIdsByProfile.get(row.profile_id) || []
|
||||||
|
current.push(row.team_id)
|
||||||
|
teamIdsByProfile.set(row.profile_id, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles.map((profile) => {
|
||||||
|
const assignedTeamIds = [...new Set(teamIdsByProfile.get(profile.id) || [])]
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
teams: assignedTeamIds
|
||||||
|
.map((teamId) => teamMap.get(teamId))
|
||||||
|
.filter(Boolean),
|
||||||
|
team_ids: assignedTeamIds,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTenantTeamIds(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
values: any[],
|
||||||
|
) {
|
||||||
|
const requestedTeamIds = normalizeTeamIds(values)
|
||||||
|
|
||||||
|
if (!requestedTeamIds.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTeams = await server.db
|
||||||
|
.select({ id: teams.id })
|
||||||
|
.from(teams)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(teams.tenant, tenantId),
|
||||||
|
inArray(teams.id, requestedTeamIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const validTeamIds = validTeams.map((team) => team.id)
|
||||||
|
|
||||||
|
if (validTeamIds.length !== requestedTeamIds.length) {
|
||||||
|
throw new Error("INVALID_TEAM_SELECTION")
|
||||||
|
}
|
||||||
|
|
||||||
|
return validTeamIds
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncProfileTeams(
|
||||||
|
server: FastifyInstance,
|
||||||
|
profileId: string,
|
||||||
|
teamIds: number[],
|
||||||
|
userId?: string | null
|
||||||
|
) {
|
||||||
|
await server.db
|
||||||
|
.delete(authProfileTeams)
|
||||||
|
.where(eq(authProfileTeams.profile_id, profileId))
|
||||||
|
|
||||||
|
if (!teamIds.length) return
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.insert(authProfileTeams)
|
||||||
|
.values(teamIds.map((teamId) => ({
|
||||||
|
profile_id: profileId,
|
||||||
|
team_id: teamId,
|
||||||
|
created_by: userId || null,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
bankaccounts,
|
bankaccounts,
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
|
branches,
|
||||||
entitybankaccounts,
|
entitybankaccounts,
|
||||||
events,
|
events,
|
||||||
contacts,
|
contacts,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
letterheads,
|
letterheads,
|
||||||
memberrelations,
|
memberrelations,
|
||||||
ownaccounts,
|
ownaccounts,
|
||||||
|
outgoingsepamandates,
|
||||||
plants,
|
plants,
|
||||||
productcategories,
|
productcategories,
|
||||||
products,
|
products,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
spaces,
|
spaces,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
tasks,
|
tasks,
|
||||||
|
teams,
|
||||||
texttemplates,
|
texttemplates,
|
||||||
units,
|
units,
|
||||||
vehicles,
|
vehicles,
|
||||||
@@ -51,7 +54,7 @@ export const resourceConfig = {
|
|||||||
},
|
},
|
||||||
customers: {
|
customers: {
|
||||||
searchColumns: ["name", "nameAddition", "customerNumber", "firstname", "lastname", "notes"],
|
searchColumns: ["name", "nameAddition", "customerNumber", "firstname", "lastname", "notes"],
|
||||||
mtmLoad: ["contacts","projects","plants","createddocuments","contracts","customerinventoryitems","customerspaces"],
|
mtmLoad: ["contacts","projects","plants","createddocuments","contracts","outgoingsepamandates","customerinventoryitems","customerspaces"],
|
||||||
table: customers,
|
table: customers,
|
||||||
numberRangeHolder: "customerNumber",
|
numberRangeHolder: "customerNumber",
|
||||||
},
|
},
|
||||||
@@ -75,7 +78,13 @@ export const resourceConfig = {
|
|||||||
table: contracts,
|
table: contracts,
|
||||||
searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
|
searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
|
||||||
numberRangeHolder: "contractNumber",
|
numberRangeHolder: "contractNumber",
|
||||||
mtoLoad: ["customer", "contracttype"],
|
mtoLoad: ["customer", "contracttype", "outgoingsepamandate"],
|
||||||
|
},
|
||||||
|
outgoingsepamandates: {
|
||||||
|
table: outgoingsepamandates,
|
||||||
|
searchColumns: ["reference", "status", "mandateType", "sequenceType", "notes"],
|
||||||
|
numberRangeHolder: "reference",
|
||||||
|
mtoLoad: ["customer", "bankaccount"],
|
||||||
},
|
},
|
||||||
contracttypes: {
|
contracttypes: {
|
||||||
table: contracttypes,
|
table: contracttypes,
|
||||||
@@ -162,9 +171,23 @@ export const resourceConfig = {
|
|||||||
costcentres: {
|
costcentres: {
|
||||||
table: costcentres,
|
table: costcentres,
|
||||||
searchColumns: ["name","number","description"],
|
searchColumns: ["name","number","description"],
|
||||||
mtoLoad: ["vehicle","project","inventoryitem"],
|
mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"],
|
||||||
numberRangeHolder: "number",
|
numberRangeHolder: "number",
|
||||||
},
|
},
|
||||||
|
parentCostcentre: {
|
||||||
|
table: costcentres,
|
||||||
|
searchColumns: ["name", "number", "description"],
|
||||||
|
},
|
||||||
|
branches: {
|
||||||
|
table: branches,
|
||||||
|
searchColumns: ["name","number","description"],
|
||||||
|
numberRangeHolder: "number",
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
table: teams,
|
||||||
|
searchColumns: ["name", "description"],
|
||||||
|
mtoLoad: ["branch"],
|
||||||
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
table: tasks,
|
table: tasks,
|
||||||
},
|
},
|
||||||
@@ -184,9 +207,9 @@ export const resourceConfig = {
|
|||||||
},
|
},
|
||||||
createddocuments: {
|
createddocuments: {
|
||||||
table: createddocuments,
|
table: createddocuments,
|
||||||
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
|
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument", "outgoingsepamandate"],
|
||||||
mtmLoad: ["statementallocations","files","createddocuments"],
|
mtmLoad: ["statementallocations","files","createddocuments"],
|
||||||
mtmListLoad: ["statementallocations"],
|
mtmListLoad: ["statementallocations", "files"],
|
||||||
},
|
},
|
||||||
texttemplates: {
|
texttemplates: {
|
||||||
table: texttemplates
|
table: texttemplates
|
||||||
@@ -219,6 +242,10 @@ export const resourceConfig = {
|
|||||||
table: entitybankaccounts,
|
table: entitybankaccounts,
|
||||||
searchColumns: ["description"],
|
searchColumns: ["description"],
|
||||||
},
|
},
|
||||||
|
bankaccount: {
|
||||||
|
table: entitybankaccounts,
|
||||||
|
searchColumns: ["description"],
|
||||||
|
},
|
||||||
serialexecutions: {
|
serialexecutions: {
|
||||||
table: serialExecutions
|
table: serialExecutions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,97 @@ export let secrets = {
|
|||||||
DOKUBOX_IMAP_PASSWORD: string
|
DOKUBOX_IMAP_PASSWORD: string
|
||||||
OPENAI_API_KEY: string
|
OPENAI_API_KEY: string
|
||||||
STIRLING_API_KEY: string
|
STIRLING_API_KEY: string
|
||||||
|
MATRIX_HOMESERVER_URL?: string
|
||||||
|
MATRIX_SERVER_NAME?: string
|
||||||
|
MATRIX_RTC_HOST?: string
|
||||||
|
MATRIX_RTC_JWT_URL?: string
|
||||||
|
MATRIX_LIVEKIT_URL?: string
|
||||||
|
MATRIX_REGISTRATION_SHARED_SECRET?: string
|
||||||
|
MATRIX_SERVICE_USER_LOCALPART?: string
|
||||||
|
LIVEKIT_KEY?: string
|
||||||
|
LIVEKIT_SECRET?: string
|
||||||
|
WEB_PUSH_PUBLIC_KEY?: string
|
||||||
|
WEB_PUSH_PRIVATE_KEY?: string
|
||||||
|
WEB_PUSH_SUBJECT?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretKeys = [
|
||||||
|
"COOKIE_SECRET",
|
||||||
|
"JWT_SECRET",
|
||||||
|
"PORT",
|
||||||
|
"HOST",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"S3_BUCKET",
|
||||||
|
"ENCRYPTION_KEY",
|
||||||
|
"MAILER_SMTP_HOST",
|
||||||
|
"MAILER_SMTP_PORT",
|
||||||
|
"MAILER_SMTP_SSL",
|
||||||
|
"MAILER_SMTP_USER",
|
||||||
|
"MAILER_SMTP_PASS",
|
||||||
|
"MAILER_FROM",
|
||||||
|
"S3_ENDPOINT",
|
||||||
|
"S3_REGION",
|
||||||
|
"S3_ACCESS_KEY",
|
||||||
|
"S3_SECRET_KEY",
|
||||||
|
"M2M_API_KEY",
|
||||||
|
"API_BASE_URL",
|
||||||
|
"GOCARDLESS_BASE_URL",
|
||||||
|
"GOCARDLESS_SECRET_ID",
|
||||||
|
"GOCARDLESS_SECRET_KEY",
|
||||||
|
"DOKUBOX_IMAP_HOST",
|
||||||
|
"DOKUBOX_IMAP_PORT",
|
||||||
|
"DOKUBOX_IMAP_SECURE",
|
||||||
|
"DOKUBOX_IMAP_USER",
|
||||||
|
"DOKUBOX_IMAP_PASSWORD",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
"STIRLING_API_KEY",
|
||||||
|
"MATRIX_HOMESERVER_URL",
|
||||||
|
"MATRIX_SERVER_NAME",
|
||||||
|
"MATRIX_RTC_HOST",
|
||||||
|
"MATRIX_RTC_JWT_URL",
|
||||||
|
"MATRIX_LIVEKIT_URL",
|
||||||
|
"MATRIX_REGISTRATION_SHARED_SECRET",
|
||||||
|
"MATRIX_SERVICE_USER_LOCALPART",
|
||||||
|
"LIVEKIT_KEY",
|
||||||
|
"LIVEKIT_SECRET",
|
||||||
|
"WEB_PUSH_PUBLIC_KEY",
|
||||||
|
"WEB_PUSH_PRIVATE_KEY",
|
||||||
|
"WEB_PUSH_SUBJECT",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
|
||||||
|
const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE"])
|
||||||
|
|
||||||
|
function normalizeEnvValue(key: string, value: string) {
|
||||||
|
if (numberKeys.has(key)) return Number(value)
|
||||||
|
if (booleanKeys.has(key)) return value === "true"
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSecretsFromEnv() {
|
||||||
|
let loaded = 0
|
||||||
|
|
||||||
|
secretKeys.forEach((key) => {
|
||||||
|
const value = process.env[key]
|
||||||
|
if (value === undefined || value === "") return
|
||||||
|
|
||||||
|
;(secrets as Record<string, any>)[key] = normalizeEnvValue(key, value)
|
||||||
|
loaded++
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!secrets.HOST) secrets.HOST = "0.0.0.0"
|
||||||
|
if (!secrets.PORT) secrets.PORT = 3100
|
||||||
|
|
||||||
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSecrets () {
|
export async function loadSecrets () {
|
||||||
|
const envSecretCount = loadSecretsFromEnv()
|
||||||
|
|
||||||
|
if (!process.env.INFISICAL_CLIENT_ID || !process.env.INFISICAL_CLIENT_SECRET) {
|
||||||
|
console.log(`✅ Secrets aus Umgebungsvariablen geladen (${envSecretCount} Stück)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await client.auth().universalAuth.login({
|
await client.auth().universalAuth.login({
|
||||||
clientId: process.env.INFISICAL_CLIENT_ID,
|
clientId: process.env.INFISICAL_CLIENT_ID,
|
||||||
@@ -53,9 +141,9 @@ export async function loadSecrets () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
allSecrets.secrets.forEach(secret => {
|
allSecrets.secrets.forEach(secret => {
|
||||||
secrets[secret.secretKey] = secret.secretValue
|
;(secrets as Record<string, any>)[secret.secretKey] = normalizeEnvValue(secret.secretKey, secret.secretValue)
|
||||||
})
|
})
|
||||||
console.log("✅ Secrets aus Infisical geladen");
|
loadSecretsFromEnv()
|
||||||
|
console.log("✅ Secrets aus Infisical und Umgebungsvariablen geladen");
|
||||||
console.log(Object.keys(secrets).length + " Stück")
|
console.log(Object.keys(secrets).length + " Stück")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
452
backend/src/utils/tenantFullExport.ts
Normal file
452
backend/src/utils/tenantFullExport.ts
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||||
|
|
||||||
|
import { pool } from "../../db"
|
||||||
|
import { s3 } from "./s3"
|
||||||
|
import { secrets } from "./secrets"
|
||||||
|
|
||||||
|
type TableRows = Record<string, Record<string, any>[]>
|
||||||
|
type TableMetadata = {
|
||||||
|
columns: string[]
|
||||||
|
jsonColumns: Set<string>
|
||||||
|
generatedColumns: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TenantFullExport = {
|
||||||
|
format: "fedeo.tenant-full-export"
|
||||||
|
version: 1
|
||||||
|
exportedAt: string
|
||||||
|
tenantId: number
|
||||||
|
tables: TableRows
|
||||||
|
files: {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
name: string | null
|
||||||
|
mimeType: string | null
|
||||||
|
size: number | null
|
||||||
|
contentBase64: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportResult = {
|
||||||
|
tenantId: number
|
||||||
|
tables: { table: string; rows: number }[]
|
||||||
|
files: { restored: number; skipped: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportOptions = {
|
||||||
|
targetTenantId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
|
||||||
|
|
||||||
|
const tableColumns = async (client: any) => {
|
||||||
|
const result = await client.query(`
|
||||||
|
select table_name, column_name, data_type, is_generated
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
order by table_name, ordinal_position
|
||||||
|
`)
|
||||||
|
|
||||||
|
const columnsByTable = new Map<string, TableMetadata>()
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const metadata = columnsByTable.get(row.table_name) || {
|
||||||
|
columns: [],
|
||||||
|
jsonColumns: new Set<string>(),
|
||||||
|
generatedColumns: new Set<string>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.columns.push(row.column_name)
|
||||||
|
if (row.data_type === "json" || row.data_type === "jsonb") {
|
||||||
|
metadata.jsonColumns.add(row.column_name)
|
||||||
|
}
|
||||||
|
if (row.is_generated === "ALWAYS") {
|
||||||
|
metadata.generatedColumns.add(row.column_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
columnsByTable.set(row.table_name, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
return columnsByTable
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRows = async (client: any, table: string, whereSql: string, params: any[] = []) => {
|
||||||
|
const result = await client.query(`select * from ${quoteIdent(table)} where ${whereSql}`, params)
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectIds = (rows: Record<string, any>[], column: string) => {
|
||||||
|
return Array.from(new Set(rows.map((row) => row[column]).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRows = (tables: TableRows, table: string, rows: Record<string, any>[]) => {
|
||||||
|
if (!rows.length) {
|
||||||
|
if (!tables[table]) tables[table] = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRows = tables[table] || []
|
||||||
|
const existingKeys = new Set(existingRows.map((row) => JSON.stringify(row)))
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = JSON.stringify(row)
|
||||||
|
if (!existingKeys.has(key)) {
|
||||||
|
existingRows.push(row)
|
||||||
|
existingKeys.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tables[table] = existingRows
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadObjectAsBase64 = async (path: string) => {
|
||||||
|
const { Body } = await s3.send(new GetObjectCommand({
|
||||||
|
Bucket: secrets.S3_BUCKET,
|
||||||
|
Key: path,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of Body as any) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks).toString("base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildTenantFullExport = async (server: FastifyInstance, tenantId: number): Promise<TenantFullExport> => {
|
||||||
|
const client = await pool.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columnsByTable = await tableColumns(client)
|
||||||
|
const tables: TableRows = {}
|
||||||
|
|
||||||
|
const tenantRows = await loadRows(client, "tenants", "id = $1", [tenantId])
|
||||||
|
if (!tenantRows.length) throw new Error("Tenant nicht gefunden")
|
||||||
|
|
||||||
|
addRows(tables, "tenants", tenantRows)
|
||||||
|
|
||||||
|
for (const [table, metadata] of columnsByTable.entries()) {
|
||||||
|
if (table === "tenants") continue
|
||||||
|
const { columns } = metadata
|
||||||
|
|
||||||
|
const tenantColumn = columns.includes("tenant")
|
||||||
|
? "tenant"
|
||||||
|
: columns.includes("tenant_id")
|
||||||
|
? "tenant_id"
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!tenantColumn) continue
|
||||||
|
|
||||||
|
const rows = await loadRows(client, table, `${quoteIdent(tenantColumn)} = $1`, [tenantId])
|
||||||
|
addRows(tables, table, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileIds = collectIds(tables.auth_profiles || [], "id")
|
||||||
|
const userIds = Array.from(new Set([
|
||||||
|
...collectIds(tables.auth_tenant_users || [], "user_id"),
|
||||||
|
...collectIds(tables.auth_profiles || [], "user_id"),
|
||||||
|
...collectIds(tables.auth_user_roles || [], "user_id"),
|
||||||
|
]))
|
||||||
|
const roleIds = Array.from(new Set([
|
||||||
|
...collectIds(tables.auth_roles || [], "id"),
|
||||||
|
...collectIds(tables.auth_user_roles || [], "role_id"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
if (userIds.length) {
|
||||||
|
addRows(tables, "auth_users", await loadRows(client, "auth_users", "id = any($1::uuid[])", [userIds]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleIds.length) {
|
||||||
|
addRows(tables, "auth_roles", await loadRows(client, "auth_roles", "id = any($1::uuid[])", [roleIds]))
|
||||||
|
addRows(tables, "auth_role_permissions", await loadRows(client, "auth_role_permissions", "role_id = any($1::uuid[])", [roleIds]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileIds.length) {
|
||||||
|
addRows(tables, "auth_profile_branches", await loadRows(client, "auth_profile_branches", "profile_id = any($1::uuid[])", [profileIds]))
|
||||||
|
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileRows = tables.files || []
|
||||||
|
const files = []
|
||||||
|
|
||||||
|
for (const file of fileRows) {
|
||||||
|
if (!file.path) continue
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
id: file.id,
|
||||||
|
path: file.path,
|
||||||
|
name: file.name || null,
|
||||||
|
mimeType: file.mimeType || null,
|
||||||
|
size: file.size || null,
|
||||||
|
contentBase64: await loadObjectAsBase64(file.path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: "fedeo.tenant-full-export",
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
tenantId,
|
||||||
|
tables,
|
||||||
|
files,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreFiles = async (exportData: TenantFullExport) => {
|
||||||
|
let restored = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const file of exportData.files || []) {
|
||||||
|
if (!file.path || !file.contentBase64) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.from(file.contentBase64, "base64")
|
||||||
|
await s3.send(new PutObjectCommand({
|
||||||
|
Bucket: secrets.S3_BUCKET,
|
||||||
|
Key: file.path,
|
||||||
|
Body: body,
|
||||||
|
ContentType: file.mimeType || "application/octet-stream",
|
||||||
|
ContentLength: body.length,
|
||||||
|
}))
|
||||||
|
restored += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return { restored, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
const remapTenantScopedExport = (
|
||||||
|
exportData: TenantFullExport,
|
||||||
|
targetTenantId?: number | null
|
||||||
|
): TenantFullExport => {
|
||||||
|
if (!targetTenantId || targetTenantId === exportData.tenantId) return exportData
|
||||||
|
|
||||||
|
const sourceTenantId = exportData.tenantId
|
||||||
|
const sourcePathPrefix = `${sourceTenantId}/`
|
||||||
|
const targetPathPrefix = `${targetTenantId}/`
|
||||||
|
const tables: TableRows = {}
|
||||||
|
|
||||||
|
for (const [table, rows] of Object.entries(exportData.tables || {})) {
|
||||||
|
tables[table] = rows.map((row) => {
|
||||||
|
const nextRow = { ...row }
|
||||||
|
|
||||||
|
if (table === "tenants" && nextRow.id === sourceTenantId) {
|
||||||
|
nextRow.id = targetTenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextRow.tenant === sourceTenantId) {
|
||||||
|
nextRow.tenant = targetTenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextRow.tenant_id === sourceTenantId) {
|
||||||
|
nextRow.tenant_id = targetTenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table === "files" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) {
|
||||||
|
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextRow
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...exportData,
|
||||||
|
tenantId: targetTenantId,
|
||||||
|
tables,
|
||||||
|
files: (exportData.files || []).map((file) => ({
|
||||||
|
...file,
|
||||||
|
path: file.path?.startsWith(sourcePathPrefix)
|
||||||
|
? `${targetPathPrefix}${file.path.slice(sourcePathPrefix.length)}`
|
||||||
|
: file.path,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
|
||||||
|
if (!isJsonColumn || value === null || typeof value === "undefined") return value
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertRows = async (client: any, table: string, rows: Record<string, any>[], metadata: TableMetadata) => {
|
||||||
|
if (!rows.length) return 0
|
||||||
|
|
||||||
|
let inserted = 0
|
||||||
|
const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column)))
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column))
|
||||||
|
if (!rowColumns.length) continue
|
||||||
|
|
||||||
|
const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
|
||||||
|
const values = rowColumns.map((column) => prepareColumnValue(row[column], metadata.jsonColumns.has(column)))
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) on conflict do nothing`,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertIdentityRowsWithRemap = async (
|
||||||
|
client: any,
|
||||||
|
table: string,
|
||||||
|
rows: Record<string, any>[],
|
||||||
|
metadata: TableMetadata
|
||||||
|
) => {
|
||||||
|
if (!rows.length) return { count: 0, idMap: new Map<number, number>() }
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
const idMap = new Map<number, number>()
|
||||||
|
const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column)))
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const sourceId = Number(row.id)
|
||||||
|
if (!sourceId) continue
|
||||||
|
|
||||||
|
const existing = await client.query(`select * from ${quoteIdent(table)} where ${quoteIdent("id")} = $1 limit 1`, [sourceId])
|
||||||
|
const existingTenant = existing.rows[0]?.tenant ?? existing.rows[0]?.tenant_id
|
||||||
|
const rowTenant = row.tenant ?? row.tenant_id
|
||||||
|
const shouldPreserveId = existing.rows.length === 0
|
||||||
|
const shouldReuseExisting = existing.rows.length > 0 && rowTenant && existingTenant === rowTenant
|
||||||
|
const rowForInsert = shouldPreserveId ? row : { ...row, id: undefined }
|
||||||
|
const rowColumns = Object
|
||||||
|
.keys(rowForInsert)
|
||||||
|
.filter((column) => availableColumns.has(column) && typeof rowForInsert[column] !== "undefined")
|
||||||
|
|
||||||
|
if (shouldReuseExisting) {
|
||||||
|
idMap.set(sourceId, sourceId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rowColumns.length) continue
|
||||||
|
|
||||||
|
const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
|
||||||
|
const values = rowColumns.map((column) => prepareColumnValue(rowForInsert[column], metadata.jsonColumns.has(column)))
|
||||||
|
const inserted = await client.query(
|
||||||
|
`insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) returning ${quoteIdent("id")}`,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
const targetId = Number(inserted.rows[0]?.id)
|
||||||
|
if (targetId) {
|
||||||
|
idMap.set(sourceId, targetId)
|
||||||
|
}
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count, idMap }
|
||||||
|
}
|
||||||
|
|
||||||
|
const remapTableColumn = (rows: Record<string, any>[] = [], column: string, idMap: Map<number, number>) => {
|
||||||
|
if (!idMap.size) return
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const currentValue = Number(row[column])
|
||||||
|
const mappedValue = idMap.get(currentValue)
|
||||||
|
if (mappedValue) row[column] = mappedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshSequences = async (client: any, columnsByTable: Map<string, TableMetadata>) => {
|
||||||
|
for (const [table, metadata] of columnsByTable.entries()) {
|
||||||
|
const { columns } = metadata
|
||||||
|
if (!columns.includes("id")) continue
|
||||||
|
|
||||||
|
const sequenceResult = await client.query("select pg_get_serial_sequence($1, $2) as sequence_name", [`public.${table}`, "id"])
|
||||||
|
const sequenceName = sequenceResult.rows[0]?.sequence_name
|
||||||
|
if (!sequenceName) continue
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
select setval(
|
||||||
|
$1::regclass,
|
||||||
|
greatest(coalesce((select max(id) from ${quoteIdent(table)}), 1), 1),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
`, [sequenceName])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importTenantFullExport = async (
|
||||||
|
server: FastifyInstance,
|
||||||
|
rawExportData: TenantFullExport,
|
||||||
|
options: ImportOptions = {}
|
||||||
|
): Promise<ImportResult> => {
|
||||||
|
if (rawExportData?.format !== "fedeo.tenant-full-export" || rawExportData.version !== 1) {
|
||||||
|
throw new Error("Ungültiges FEDEO Mandantenexport-Format")
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
|
||||||
|
const client = await pool.connect()
|
||||||
|
const importOrder = [
|
||||||
|
"tenants",
|
||||||
|
"auth_users",
|
||||||
|
"auth_roles",
|
||||||
|
"auth_role_permissions",
|
||||||
|
"auth_tenant_users",
|
||||||
|
"auth_profiles",
|
||||||
|
"auth_user_roles",
|
||||||
|
"auth_profile_branches",
|
||||||
|
"auth_profile_teams",
|
||||||
|
]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columnsByTable = await tableColumns(client)
|
||||||
|
const tableNames = [
|
||||||
|
...importOrder,
|
||||||
|
...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
|
||||||
|
].filter((table, index, all) => all.indexOf(table) === index)
|
||||||
|
|
||||||
|
await client.query("begin")
|
||||||
|
await client.query("set local session_replication_role = replica")
|
||||||
|
const files = await restoreFiles(exportData)
|
||||||
|
|
||||||
|
const importedTables: { table: string; rows: number }[] = []
|
||||||
|
const bankaccountMetadata = columnsByTable.get("bankaccounts")
|
||||||
|
if (bankaccountMetadata) {
|
||||||
|
const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata)
|
||||||
|
remapTableColumn(exportData.tables.bankstatements, "account", result.idMap)
|
||||||
|
importedTables.push({ table: "bankaccounts", rows: result.count })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bankstatementMetadata = columnsByTable.get("bankstatements")
|
||||||
|
if (bankstatementMetadata) {
|
||||||
|
const result = await insertIdentityRowsWithRemap(client, "bankstatements", exportData.tables.bankstatements || [], bankstatementMetadata)
|
||||||
|
remapTableColumn(exportData.tables.statementallocations, "bs_id", result.idMap)
|
||||||
|
remapTableColumn(exportData.tables.historyitems, "bankstatement", result.idMap)
|
||||||
|
importedTables.push({ table: "bankstatements", rows: result.count })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of tableNames) {
|
||||||
|
if (["bankaccounts", "bankstatements"].includes(table)) continue
|
||||||
|
|
||||||
|
const rows = exportData.tables[table] || []
|
||||||
|
const metadata = columnsByTable.get(table)
|
||||||
|
if (!metadata) continue
|
||||||
|
|
||||||
|
const count = await insertRows(client, table, rows, metadata)
|
||||||
|
importedTables.push({ table, rows: count })
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSequences(client, columnsByTable)
|
||||||
|
await client.query("commit")
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: exportData.tenantId,
|
||||||
|
tables: importedTables,
|
||||||
|
files,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("rollback")
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
9
docker-compose.docs.yml
Normal file
9
docker-compose.docs.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
docs:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docs-site/Dockerfile
|
||||||
|
container_name: fedeo-docs
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3205:3000"
|
||||||
174
docker-compose.selfhost.yml
Normal file
174
docker-compose.selfhost.yml
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v2.11
|
||||||
|
container_name: fedeo-traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --api.insecure=false
|
||||||
|
- --api.dashboard=false
|
||||||
|
- --providers.docker=true
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL}
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||||
|
- --accesslog=true
|
||||||
|
- --accesslog.filepath=/logs/access.log
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./traefik/letsencrypt:/letsencrypt
|
||||||
|
- ./traefik/logs:/logs
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: fedeo-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: fedeo-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./minio:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
createbuckets:
|
||||||
|
image: minio/mc:latest
|
||||||
|
container_name: fedeo-minio-init
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
|
||||||
|
mc mb --ignore-existing local/${MINIO_BUCKET};
|
||||||
|
mc anonymous set private local/${MINIO_BUCKET};
|
||||||
|
exit 0;
|
||||||
|
"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
container_name: fedeo-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
createbuckets:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true}
|
||||||
|
HOST: ${HOST:-0.0.0.0}
|
||||||
|
PORT: ${PORT:-3100}
|
||||||
|
COOKIE_SECRET: ${COOKIE_SECRET}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
MAILER_SMTP_HOST: ${MAILER_SMTP_HOST}
|
||||||
|
MAILER_SMTP_PORT: ${MAILER_SMTP_PORT}
|
||||||
|
MAILER_SMTP_SSL: ${MAILER_SMTP_SSL}
|
||||||
|
MAILER_SMTP_USER: ${MAILER_SMTP_USER}
|
||||||
|
MAILER_SMTP_PASS: ${MAILER_SMTP_PASS}
|
||||||
|
MAILER_FROM: ${MAILER_FROM}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||||
|
S3_REGION: ${S3_REGION}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_BUCKET: ${S3_BUCKET}
|
||||||
|
M2M_API_KEY: ${M2M_API_KEY}
|
||||||
|
API_BASE_URL: ${API_BASE_URL}
|
||||||
|
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
|
||||||
|
GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID}
|
||||||
|
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}
|
||||||
|
DOKUBOX_IMAP_HOST: ${DOKUBOX_IMAP_HOST}
|
||||||
|
DOKUBOX_IMAP_PORT: ${DOKUBOX_IMAP_PORT}
|
||||||
|
DOKUBOX_IMAP_SECURE: ${DOKUBOX_IMAP_SECURE}
|
||||||
|
DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER}
|
||||||
|
DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
STIRLING_API_KEY: ${STIRLING_API_KEY}
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_EMAIL: ${FEDEO_BOOTSTRAP_ADMIN_EMAIL:-}
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_PASSWORD: ${FEDEO_BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME:-Admin}
|
||||||
|
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer}
|
||||||
|
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
|
||||||
|
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
|
||||||
|
- traefik.http.routers.fedeo-backend.entrypoints=websecure
|
||||||
|
- traefik.http.routers.fedeo-backend.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
|
||||||
|
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
|
||||||
|
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
|
||||||
|
- traefik.docker.network=fedeo_web
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
- internal
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
container_name: fedeo-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
|
||||||
|
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
|
||||||
|
NUXT_PUBLIC_MATRIX_ELEMENT_URL: ${NUXT_PUBLIC_MATRIX_ELEMENT_URL:-}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
|
||||||
|
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
|
||||||
|
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
|
||||||
|
- traefik.docker.network=fedeo_web
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
name: fedeo_web
|
||||||
|
driver: bridge
|
||||||
|
internal:
|
||||||
|
name: fedeo_internal
|
||||||
|
driver: bridge
|
||||||
@@ -17,10 +17,35 @@ services:
|
|||||||
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
|
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
|
||||||
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
||||||
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
|
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
|
||||||
|
- "traefik.http.routers.fedeo-frontend.priority=1"
|
||||||
# Web Secure Entrypoint
|
# Web Secure Entrypoint
|
||||||
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
||||||
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
|
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
|
||||||
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
|
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
|
||||||
|
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
|
||||||
|
docs:
|
||||||
|
image: git.federspiel.tech/flfeders/fedeo/docs:dev
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik"
|
||||||
|
- "traefik.port=3000"
|
||||||
|
# Middlewares
|
||||||
|
- "traefik.http.middlewares.fedeo-docs-redirect-web-secure.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.fedeo-docs-strip.stripprefix.prefixes=/docs"
|
||||||
|
# Web Entrypoint
|
||||||
|
- "traefik.http.routers.fedeo-docs.middlewares=fedeo-docs-redirect-web-secure"
|
||||||
|
- "traefik.http.routers.fedeo-docs.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
|
||||||
|
- "traefik.http.routers.fedeo-docs.entrypoints=web"
|
||||||
|
- "traefik.http.routers.fedeo-docs.priority=120"
|
||||||
|
# Web Secure Entrypoint
|
||||||
|
- "traefik.http.routers.fedeo-docs-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
|
||||||
|
- "traefik.http.routers.fedeo-docs-secure.entrypoints=web-secured"
|
||||||
|
- "traefik.http.routers.fedeo-docs-secure.tls.certresolver=mytlschallenge"
|
||||||
|
- "traefik.http.routers.fedeo-docs-secure.middlewares=fedeo-docs-strip"
|
||||||
|
- "traefik.http.routers.fedeo-docs-secure.priority=120"
|
||||||
backend:
|
backend:
|
||||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
||||||
restart: always
|
restart: always
|
||||||
@@ -28,6 +53,9 @@ services:
|
|||||||
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
|
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
|
||||||
- INFISICAL_CLIENT_SECRET=4e3441acc0adbffd324aa50e668a95a556a3f55ec6bb85954e176e35a3392003
|
- INFISICAL_CLIENT_SECRET=4e3441acc0adbffd324aa50e668a95a556a3f55ec6bb85954e176e35a3392003
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- WEB_PUSH_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-}
|
||||||
|
- WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-}
|
||||||
|
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
labels:
|
labels:
|
||||||
@@ -46,6 +74,311 @@ services:
|
|||||||
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
|
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
|
||||||
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
|
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
|
||||||
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
|
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
|
||||||
|
matrix-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${MATRIX_POSTGRES_DB:-synapse}
|
||||||
|
- POSTGRES_USER=${MATRIX_POSTGRES_USER:-synapse}
|
||||||
|
- POSTGRES_PASSWORD=${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password}
|
||||||
|
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --lc-collate=C --lc-ctype=C
|
||||||
|
volumes:
|
||||||
|
- ./matrix/postgres:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-synapse:
|
||||||
|
image: ghcr.io/element-hq/synapse:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix
|
||||||
|
depends_on:
|
||||||
|
- matrix-db
|
||||||
|
- matrix-redis
|
||||||
|
environment:
|
||||||
|
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
|
||||||
|
volumes:
|
||||||
|
- ./matrix/synapse:/data
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik"
|
||||||
|
- "traefik.port=8008"
|
||||||
|
- "traefik.http.services.fedeo-matrix.loadbalancer.server.port=8008"
|
||||||
|
# Matrix Client-Server API
|
||||||
|
- "traefik.http.routers.fedeo-matrix.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix`)"
|
||||||
|
- "traefik.http.routers.fedeo-matrix.entrypoints=web"
|
||||||
|
- "traefik.http.routers.fedeo-matrix.middlewares=fedeo-matrix-redirect-web-secure"
|
||||||
|
- "traefik.http.routers.fedeo-matrix.service=fedeo-matrix"
|
||||||
|
- "traefik.http.middlewares.fedeo-matrix-redirect-web-secure.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-secure.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix`)"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-secure.entrypoints=web-secured"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-secure.tls.certresolver=mytlschallenge"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-secure.service=fedeo-matrix"
|
||||||
|
# Matrix Federation API, nur öffnen wenn Federation gewünscht ist.
|
||||||
|
- "traefik.http.routers.fedeo-matrix-federation.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix/federation`)"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-federation.entrypoints=web-secured"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-federation.tls.certresolver=mytlschallenge"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-federation.service=fedeo-matrix"
|
||||||
|
|
||||||
|
matrix-well-known:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix
|
||||||
|
volumes:
|
||||||
|
- ./matrix/well-known:/usr/share/nginx/html/.well-known/matrix:ro
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik"
|
||||||
|
- "traefik.port=80"
|
||||||
|
- "traefik.http.services.fedeo-matrix-well-known.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolalloworiginlist=*"
|
||||||
|
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowmethods=GET,OPTIONS"
|
||||||
|
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowheaders=Content-Type,Authorization"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-well-known.rule=Host(`${MATRIX_SERVER_NAME:-fedeo.de}`) && PathPrefix(`/.well-known/matrix`)"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-well-known.entrypoints=web-secured"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-well-known.tls.certresolver=mytlschallenge"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-well-known.middlewares=fedeo-matrix-well-known-cors"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-well-known.service=fedeo-matrix-well-known"
|
||||||
|
|
||||||
|
matrix-turn:
|
||||||
|
image: instrumentisto/coturn:4
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix
|
||||||
|
command:
|
||||||
|
- --fingerprint
|
||||||
|
- --use-auth-secret
|
||||||
|
- --static-auth-secret=${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret}
|
||||||
|
- --realm=${MATRIX_SERVER_NAME:-fedeo.de}
|
||||||
|
- --listening-port=3478
|
||||||
|
- --tls-listening-port=5349
|
||||||
|
- --min-port=49160
|
||||||
|
- --max-port=49200
|
||||||
|
- --no-cli
|
||||||
|
- --no-tlsv1
|
||||||
|
- --no-tlsv1_1
|
||||||
|
ports:
|
||||||
|
- "3478:3478/tcp"
|
||||||
|
- "3478:3478/udp"
|
||||||
|
- "5349:5349/tcp"
|
||||||
|
- "49160-49200:49160-49200/udp"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-livekit:
|
||||||
|
image: livekit/livekit-server:v1.9
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix
|
||||||
|
depends_on:
|
||||||
|
- matrix-redis
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command:
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
cat >/tmp/livekit.yaml <<EOF
|
||||||
|
port: 7880
|
||||||
|
redis:
|
||||||
|
address: matrix-redis:6379
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881
|
||||||
|
port_range_start: 50000
|
||||||
|
port_range_end: 50100
|
||||||
|
use_external_ip: true
|
||||||
|
keys:
|
||||||
|
${LIVEKIT_KEY:-fedeo-livekit}: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
|
||||||
|
room:
|
||||||
|
auto_create: true
|
||||||
|
EOF
|
||||||
|
exec /livekit-server --config /tmp/livekit.yaml
|
||||||
|
ports:
|
||||||
|
- "7881:7881/tcp"
|
||||||
|
- "50000-50100:50000-50100/udp"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik"
|
||||||
|
- "traefik.port=7880"
|
||||||
|
- "traefik.http.services.fedeo-matrix-livekit.loadbalancer.server.port=7880"
|
||||||
|
- "traefik.http.middlewares.fedeo-matrix-livekit-strip.stripprefix.prefixes=/livekit/sfu"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-livekit.rule=Host(`${MATRIX_RTC_HOST:-call.fedeo.de}`) && PathPrefix(`/livekit/sfu`)"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-livekit.entrypoints=web-secured"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-livekit.tls.certresolver=mytlschallenge"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-livekit.middlewares=fedeo-matrix-livekit-strip"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-livekit.service=fedeo-matrix-livekit"
|
||||||
|
|
||||||
|
matrix-rtc-jwt:
|
||||||
|
image: ghcr.io/element-hq/lk-jwt-service:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix
|
||||||
|
depends_on:
|
||||||
|
- matrix-livekit
|
||||||
|
- matrix-synapse
|
||||||
|
environment:
|
||||||
|
- LIVEKIT_URL=wss://${MATRIX_RTC_HOST:-call.fedeo.de}/livekit/sfu
|
||||||
|
- LIVEKIT_KEY=${LIVEKIT_KEY:-fedeo-livekit}
|
||||||
|
- LIVEKIT_SECRET=${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
|
||||||
|
- LIVEKIT_FULL_ACCESS_HOMESERVERS=${MATRIX_SERVER_NAME:-fedeo.de}
|
||||||
|
- LIVEKIT_JWT_BIND=:8080
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik"
|
||||||
|
- "traefik.port=8080"
|
||||||
|
- "traefik.http.services.fedeo-matrix-rtc-jwt.loadbalancer.server.port=8080"
|
||||||
|
- "traefik.http.middlewares.fedeo-matrix-rtc-jwt-strip.stripprefix.prefixes=/livekit/jwt"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-rtc-jwt.rule=Host(`${MATRIX_RTC_HOST:-call.fedeo.de}`) && PathPrefix(`/livekit/jwt`)"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-rtc-jwt.entrypoints=web-secured"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-rtc-jwt.tls.certresolver=mytlschallenge"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-rtc-jwt.middlewares=fedeo-matrix-rtc-jwt-strip"
|
||||||
|
- "traefik.http.routers.fedeo-matrix-rtc-jwt.service=fedeo-matrix-rtc-jwt"
|
||||||
|
|
||||||
|
matrix-dev-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix-dev
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=synapse
|
||||||
|
- POSTGRES_USER=synapse
|
||||||
|
- POSTGRES_PASSWORD=synapse-dev-password
|
||||||
|
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --lc-collate=C --lc-ctype=C
|
||||||
|
volumes:
|
||||||
|
- ./matrix/dev/postgres:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-dev-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix-dev
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-dev-synapse:
|
||||||
|
image: ghcr.io/element-hq/synapse:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix-dev
|
||||||
|
depends_on:
|
||||||
|
- matrix-dev-db
|
||||||
|
- matrix-dev-redis
|
||||||
|
environment:
|
||||||
|
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
|
||||||
|
volumes:
|
||||||
|
- ./matrix/dev/synapse:/data
|
||||||
|
ports:
|
||||||
|
- "${MATRIX_DEV_SYNAPSE_PORT:-8008}:8008"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-dev-turn:
|
||||||
|
image: instrumentisto/coturn:4
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix-dev
|
||||||
|
command:
|
||||||
|
- --fingerprint
|
||||||
|
- --use-auth-secret
|
||||||
|
- --static-auth-secret=matrix-dev-turn-secret
|
||||||
|
- --realm=localhost
|
||||||
|
- --listening-port=3478
|
||||||
|
- --min-port=49160
|
||||||
|
- --max-port=49200
|
||||||
|
- --no-cli
|
||||||
|
- --no-tls
|
||||||
|
- --no-dtls
|
||||||
|
ports:
|
||||||
|
- "${MATRIX_DEV_TURN_PORT:-3478}:3478/tcp"
|
||||||
|
- "${MATRIX_DEV_TURN_PORT:-3478}:3478/udp"
|
||||||
|
- "${MATRIX_DEV_TURN_MIN_PORT:-49160}-${MATRIX_DEV_TURN_MAX_PORT:-49200}:49160-49200/udp"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-dev-livekit:
|
||||||
|
image: livekit/livekit-server:v1.9
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix-dev
|
||||||
|
depends_on:
|
||||||
|
- matrix-dev-redis
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command:
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
cat >/tmp/livekit.yaml <<EOF
|
||||||
|
port: 7880
|
||||||
|
redis:
|
||||||
|
address: matrix-dev-redis:6379
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881
|
||||||
|
port_range_start: 50000
|
||||||
|
port_range_end: 50100
|
||||||
|
node_ip: ${MATRIX_DEV_LIVEKIT_NODE_IP:-127.0.0.1}
|
||||||
|
use_external_ip: false
|
||||||
|
enable_loopback_candidate: true
|
||||||
|
keys:
|
||||||
|
devkey: devsecret-local-matrix-stack-32-chars
|
||||||
|
room:
|
||||||
|
auto_create: true
|
||||||
|
EOF
|
||||||
|
exec /livekit-server --config /tmp/livekit.yaml
|
||||||
|
ports:
|
||||||
|
- "${MATRIX_DEV_LIVEKIT_PORT:-7880}:7880"
|
||||||
|
- "${MATRIX_DEV_LIVEKIT_TCP_PORT:-7881}:7881/tcp"
|
||||||
|
- "${MATRIX_DEV_LIVEKIT_RTC_MIN_PORT:-50000}-${MATRIX_DEV_LIVEKIT_RTC_MAX_PORT:-50100}:50000-50100/udp"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-dev-rtc-jwt:
|
||||||
|
image: ghcr.io/element-hq/lk-jwt-service:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix-dev
|
||||||
|
depends_on:
|
||||||
|
- matrix-dev-livekit
|
||||||
|
- matrix-dev-synapse
|
||||||
|
environment:
|
||||||
|
- LIVEKIT_URL=ws://localhost:${MATRIX_DEV_LIVEKIT_PORT:-7880}
|
||||||
|
- LIVEKIT_KEY=devkey
|
||||||
|
- LIVEKIT_SECRET=devsecret-local-matrix-stack-32-chars
|
||||||
|
- LIVEKIT_FULL_ACCESS_HOMESERVERS=localhost
|
||||||
|
- LIVEKIT_JWT_BIND=:8080
|
||||||
|
ports:
|
||||||
|
- "${MATRIX_DEV_RTC_JWT_PORT:-8081}:8080"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
matrix-dev-element:
|
||||||
|
image: vectorim/element-web:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- matrix-dev
|
||||||
|
volumes:
|
||||||
|
- ./matrix/dev/element-config.json:/app/config.json:ro
|
||||||
|
ports:
|
||||||
|
- "${MATRIX_DEV_ELEMENT_PORT:-8080}:80"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
# db:
|
# db:
|
||||||
# image: postgres
|
# image: postgres
|
||||||
# restart: always
|
# restart: always
|
||||||
@@ -90,4 +423,4 @@ services:
|
|||||||
- traefik
|
- traefik
|
||||||
networks:
|
networks:
|
||||||
traefik:
|
traefik:
|
||||||
external: false
|
external: false
|
||||||
|
|||||||
3
docs-site/.dockerignore
Normal file
3
docs-site/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
3
docs-site/.gitignore
vendored
Normal file
3
docs-site/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
19
docs-site/Dockerfile
Normal file
19
docs-site/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app/docs-site
|
||||||
|
|
||||||
|
COPY docs-site/package.json docs-site/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY docs-site ./
|
||||||
|
COPY docs /app/docs
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app/docs-site
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=builder /app/docs-site/.output ./.output
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user