Compare commits
295 Commits
main
...
80b2b1d097
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 03bcc1a939 | |||
| 68b2cbb0ee | |||
| b009ac845f | |||
| cfd84b773f | |||
| 8038f03406 | |||
| 6c3c318f86 | |||
| 8dfcffc92b | |||
| 9ecacdab50 | |||
| 44fb50b11e | |||
| 23c4d21f44 | |||
| 6f77bccd85 | |||
| be336a51ab | |||
| ac2e2fcfe9 | |||
| 9dbb194c8a | |||
| 0aacb18aaa | |||
| e3a1636018 | |||
| 55bb2589a4 | |||
| 05d99e9e7d | |||
| 7e0a2f5e4f | |||
| 84c174ca09 | |||
| a9d3d0038f | |||
| 003d88587a | |||
| 69ff646689 | |||
| 1511340f00 | |||
| 62accb5a86 | |||
| 8c935c6101 | |||
| f6bdf2906f | |||
| dff3a23c04 | |||
| 966c121cbf | |||
| da50782ffc | |||
| 6919de096a | |||
| 8892b36ae5 | |||
| 8a08147265 | |||
| 52c182cb5f | |||
| 9cef3964e9 | |||
| cf0fb724a2 | |||
| bbb893dd6c | |||
| 724f152d70 | |||
| 27be8241bf | |||
| d27e437ba6 | |||
| f5253b29f4 | |||
| 0141a243ce | |||
| a0e1b8c0eb | |||
| 45fb45845a | |||
| 409db82368 | |||
| 30d761f899 | |||
| 70636f6ac5 | |||
| 59392a723c | |||
| c782492ab5 | |||
| 844af30b18 | |||
| 6fded3993a | |||
| f26d6bd4f3 | |||
| 2621cc0d8d | |||
| a8238dc9ba | |||
| 49d35f080d | |||
| 189a52b3cd | |||
| 3f8ce5daf7 | |||
| 087ba1126e | |||
| db4e9612a0 | |||
| cb4917c536 | |||
| 9f32eb5439 | |||
| f596b46364 | |||
| 117da523d2 | |||
| c2901dc0a9 | |||
| 8c2a8a7998 | |||
| 1dc74947f4 | |||
| f63e793c88 | |||
| 29a84b899d | |||
| be706a70f8 | |||
| 474b3e762c | |||
| f793d4cce6 | |||
| c3f46cd184 | |||
| 6bf336356d | |||
| 55699da42c | |||
| 053f184a33 | |||
| 6541cb2adf | |||
| 7dca84947e | |||
| 45fd6fda08 | |||
| 31e80fb386 | |||
| 7ea28cc6c0 | |||
| c0faa398b8 | |||
| 19be1f0d03 | |||
| c43d3225e3 | |||
| 7125d15b3f | |||
| 4b7cf171c8 | |||
| 59fdedfaa0 | |||
| 71d249d8bf | |||
| e496a62b36 | |||
| 0bfef0806b | |||
| 5c69388f1c | |||
| 7ed0388acb | |||
| 3aa0c7d77a | |||
| 77aa277347 | |||
| 2fff1ca8a8 | |||
| e58929d9a0 | |||
| 90560ecd2c | |||
| b07953fb7d | |||
| 01ef3c5a42 | |||
| 2aed851224 | |||
| c56fcfbd14 | |||
| ca2020b9c6 | |||
| c87212d54a | |||
| db22d47900 | |||
| 143485e107 | |||
| c1d4b24418 | |||
| 9655d4fa05 | |||
| 4efe452f1c | |||
| cb21a85736 | |||
| d2b70e5883 | |||
| 1a065b649c | |||
| 34c58c3755 | |||
| 37d8a414d3 | |||
| 7f4f232c32 | |||
| d6f257bcc6 | |||
| 3109f4d5ff | |||
| 235b33ae08 | |||
| 2d135b7068 | |||
| 8831320a4c | |||
| 000d409e4d | |||
| 160124a184 | |||
| 26dad422ec | |||
| e59cbade53 | |||
| 6423886930 | |||
| 6adf09faa0 | |||
| d7f3920763 | |||
| 3af92ebf71 | |||
| 5ab90830a0 | |||
| 4f72919269 | |||
| f2c9dcc900 | |||
| b4ec792cc0 | |||
| 9b3f48defe | |||
| 5edc90bd4d | |||
| d140251aa0 | |||
| e7fb2df5c7 | |||
| f27fd3f6da | |||
| d3e2b106af | |||
| 769d2059ca | |||
| 53349fae83 | |||
| d8eb1559c8 |
103
.env.example
Normal file
103
.env.example
Normal file
@@ -0,0 +1,103 @@
|
||||
# FEDEO Selfhosting
|
||||
DOMAIN=app.example.com
|
||||
CONTACT_EMAIL=admin@example.com
|
||||
|
||||
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
|
||||
run-name: Build Backend & Frontend by @${{ github.actor }}
|
||||
run-name: Build Backend, Frontend & Docs by @${{ github.actor }}
|
||||
|
||||
on: [push]
|
||||
|
||||
@@ -8,12 +8,38 @@ env:
|
||||
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
|
||||
# Beispiel: gitea.deine-domain.de
|
||||
REGISTRY_HOST: git.federspiel.tech
|
||||
# Der Name des Repos (z.B. user/repo)
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
# Der Name des Repos (z.B. user/repo).
|
||||
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
|
||||
IMAGE_NAME: flfeders/fedeo
|
||||
ACTOR: flfeders
|
||||
|
||||
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:
|
||||
#needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
@@ -46,6 +72,7 @@ jobs:
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
|
||||
build-frontend:
|
||||
#needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
@@ -74,4 +101,37 @@ jobs:
|
||||
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
||||
push: true
|
||||
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/
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
12
.idea/FEDEO.iml
generated
Normal file
12
.idea/FEDEO.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/FEDEO.iml" filepath="$PROJECT_DIR$/.idea/FEDEO.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
561
README.md
561
README.md
@@ -1,109 +1,468 @@
|
||||
# FEDEO Hosting Guide
|
||||
|
||||
Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO.
|
||||
|
||||
## Architektur
|
||||
|
||||
# Docker Compose Setup
|
||||
Der Stack besteht aus:
|
||||
|
||||
## ENV Vars
|
||||
- `frontend`: Nuxt-Frontend auf Port `3000`
|
||||
- `backend`: Node/Fastify-API auf Port `3100`
|
||||
- `db`: PostgreSQL
|
||||
- `traefik`: Reverse Proxy mit automatischen Let's-Encrypt-Zertifikaten
|
||||
- optional `minio`: S3-kompatibler Objektspeicher fur Dateiuploads
|
||||
|
||||
- DOMAIN
|
||||
- PDF_LICENSE
|
||||
- DB_PASS
|
||||
- DB_USER
|
||||
- CONTACT_EMAIL
|
||||
Die Konfiguration erfolgt uber Umgebungsvariablen beziehungsweise eine `.env`-Datei im Deploy-Verzeichnis.
|
||||
|
||||
## Docker Compose File
|
||||
~~~
|
||||
## Voraussetzungen
|
||||
|
||||
Vor dem Deployment sollten folgende Punkte erfullt sein:
|
||||
|
||||
- Ein Linux-Server oder VPS mit offentlichen Ports `80` und `443`
|
||||
- Docker Engine inkl. Compose Plugin
|
||||
- Eine Domain, die auf den Server zeigt, z. B. `app.example.com`
|
||||
- Optional: SMTP-Zugang fur E-Mails
|
||||
- Optional: S3-Bucket oder MinIO fur Dateispeicher
|
||||
|
||||
Empfohlen:
|
||||
|
||||
- mindestens 2 vCPU
|
||||
- mindestens 4 GB RAM
|
||||
- SSD-Speicher fur PostgreSQL und Dateiuploads
|
||||
|
||||
## DNS und Netzwerk
|
||||
|
||||
Lege mindestens einen A- oder AAAA-Record an:
|
||||
|
||||
- `app.example.com -> <SERVER-IP>`
|
||||
|
||||
Traefik terminiert TLS direkt im Compose-Stack. Es ist kein zusatzlicher Reverse Proxy davor erforderlich.
|
||||
|
||||
## Benotigte Backend-Umgebungsvariablen
|
||||
|
||||
Das Backend erwartet mindestens diese Umgebungsvariablen:
|
||||
|
||||
- `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`
|
||||
|
||||
Minimal wichtige Werte fur den ersten Start:
|
||||
|
||||
- `HOST=0.0.0.0`
|
||||
- `PORT=3100`
|
||||
- `DATABASE_URL=postgres://fedeo:<starkes-passwort>@db:5432/fedeo`
|
||||
- `API_BASE_URL=https://app.example.com/backend`
|
||||
|
||||
Wenn du MinIO verwendest, setze zusatzlich:
|
||||
|
||||
- `S3_ENDPOINT=http://minio:9000`
|
||||
- `S3_REGION=eu-central-1`
|
||||
- `S3_ACCESS_KEY=<MINIO_ROOT_USER>`
|
||||
- `S3_SECRET_KEY=<MINIO_ROOT_PASSWORD>`
|
||||
- `S3_BUCKET=fedeo`
|
||||
|
||||
## Deploy-Struktur
|
||||
|
||||
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Selfhost-Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```bash
|
||||
git clone <DEIN-REPO-URL> /opt/fedeo
|
||||
cd /opt/fedeo
|
||||
```
|
||||
|
||||
Die Verzeichnisstruktur sollte dann mindestens so aussehen:
|
||||
|
||||
```text
|
||||
/opt/fedeo/
|
||||
docker-compose.selfhost.yml
|
||||
.env
|
||||
backend/
|
||||
frontend/
|
||||
traefik/
|
||||
letsencrypt/
|
||||
logs/
|
||||
postgres/
|
||||
minio/
|
||||
```
|
||||
|
||||
Danach:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/fedeo/traefik/letsencrypt
|
||||
mkdir -p /opt/fedeo/traefik/logs
|
||||
mkdir -p /opt/fedeo/postgres
|
||||
mkdir -p /opt/fedeo/minio
|
||||
touch /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`
|
||||
|
||||
Diese Datei liegt neben der `docker-compose.yml`:
|
||||
|
||||
```env
|
||||
DOMAIN=app.example.com
|
||||
CONTACT_EMAIL=admin@example.com
|
||||
|
||||
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
|
||||
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>
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
|
||||
|
||||
## Docker Compose mit optionaler S3-MinIO-Option
|
||||
|
||||
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
|
||||
services:
|
||||
frontend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/frontend:main
|
||||
restart: always
|
||||
environment:
|
||||
- NUXT_PUBLIC_API_BASE=https://${DOMAIN}/backend
|
||||
- NUXT_PUBLIC_PDF_LICENSE=${PDF_LICENSE}
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=3000"
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
|
||||
# Web Entrypoint
|
||||
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
|
||||
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
|
||||
backend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:main
|
||||
restart: always
|
||||
environment:
|
||||
- INFISICAL_CLIENT_ID=
|
||||
- INFISICAL_CLIENT_SECRET=
|
||||
- NODE_ENV=production
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=3100"
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
|
||||
# Web Entrypoint
|
||||
- "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
|
||||
- "traefik.http.routers.fedeo-backend.entrypoints=web"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
|
||||
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
|
||||
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
|
||||
# db:
|
||||
# image: postgres
|
||||
# restart: always
|
||||
# shm_size: 128mb
|
||||
# environment:
|
||||
# POSTGRES_PASSWORD:
|
||||
# POSTGRES_USER:
|
||||
# POSTGRES_DB:
|
||||
# volumes:
|
||||
# - ./pg-data:/var/lib/postgresql/data
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
traefik:
|
||||
image: traefik:v2.11
|
||||
restart: unless-stopped
|
||||
container_name: traefik
|
||||
command:
|
||||
- "--api.insecure=false"
|
||||
- "--api.dashboard=false"
|
||||
- "--api.debug=false"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=traefik"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web-secured.address=:443"
|
||||
- "--accesslog=true"
|
||||
- "--accesslog.filepath=/logs/access.log"
|
||||
- "--accesslog.bufferingsize=5000"
|
||||
- "--accesslog.fields.defaultMode=keep"
|
||||
- "--accesslog.fields.headers.defaultMode=keep"
|
||||
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
|
||||
- "--certificatesresolvers.mytlschallenge.acme.email=${CONTACT_EMAIL}"
|
||||
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- "./traefik/logs:/logs"
|
||||
networks:
|
||||
- traefik
|
||||
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
|
||||
HOST: ${HOST}
|
||||
PORT: ${PORT}
|
||||
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}
|
||||
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
|
||||
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}
|
||||
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
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: false
|
||||
~~~
|
||||
web:
|
||||
driver: bridge
|
||||
internal:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
## Externe S3-Provider statt MinIO
|
||||
|
||||
Wenn du keinen lokalen MinIO-Container betreiben willst:
|
||||
|
||||
1. Entferne die Services `minio` und `createbuckets` aus der Compose-Datei.
|
||||
2. Entferne im Backend `depends_on` fur `minio` und `createbuckets`.
|
||||
3. Trage in `.env` die Zugangsdaten des externen S3-Dienstes ein.
|
||||
|
||||
Beispiel fur die relevanten Werte:
|
||||
|
||||
```env
|
||||
S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
|
||||
S3_REGION=eu-central-1
|
||||
S3_ACCESS_KEY=...
|
||||
S3_SECRET_KEY=...
|
||||
S3_BUCKET=fedeo
|
||||
```
|
||||
|
||||
Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit MinIO und vielen S3-kompatiblen Providern. Bei reinem AWS S3 kann je nach Endpoint-Setup ein abweichendes Verhalten sinnvoll sein. Falls du AWS S3 einsetzen willst, sollte die S3-Initialisierung im Backend gegen den konkreten Zielprovider getestet werden.
|
||||
|
||||
## Start des Stacks
|
||||
|
||||
Im Deploy-Verzeichnis:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml build
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Danach Status prufen:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml ps
|
||||
docker compose -f docker-compose.selfhost.yml logs -f traefik
|
||||
docker compose -f docker-compose.selfhost.yml logs -f backend
|
||||
```
|
||||
|
||||
Wenn du Migrationen manuell ausführen möchtest:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml run --rm backend npm run migrate
|
||||
```
|
||||
|
||||
## Funktionsprufung
|
||||
|
||||
Nach dem ersten Start sollten mindestens diese Checks erfolgreich sein:
|
||||
|
||||
```bash
|
||||
curl -I https://app.example.com
|
||||
curl https://app.example.com/backend/health
|
||||
```
|
||||
|
||||
Erwartung:
|
||||
|
||||
- Frontend liefert `200` oder `302`
|
||||
- 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
|
||||
|
||||
Bei neuen Versionen:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml build
|
||||
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.
|
||||
|
||||
## Backup-Empfehlung
|
||||
|
||||
Regelmassig sichern:
|
||||
|
||||
- `./postgres`
|
||||
- `./minio` falls MinIO lokal genutzt wird
|
||||
- `./traefik/letsencrypt/acme.json`
|
||||
- deine `.env`
|
||||
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
|
||||
|
||||
## Bekannte Betriebsbesonderheiten
|
||||
|
||||
- Das Backend startet nur sauber, wenn alle Pflichtvariablen gesetzt sind.
|
||||
- Ohne korrekt gesetzte S3-Secrets funktionieren Dateiuploads und dateibasierte Funktionen nicht.
|
||||
- Fur die Frontend-PDF-Funktion wird eine gueltige `NUXT_PUBLIC_PDF_LICENSE` benotigt.
|
||||
- PostgreSQL ist im Projekt vorgesehen; andere SQL-Datenbanken sind in dieser Compose-Datei nicht berucksichtigt.
|
||||
|
||||
## Optional: Nur mit bestehender externer Infrastruktur
|
||||
|
||||
Wenn bereits vorhanden:
|
||||
|
||||
- externer Reverse Proxy
|
||||
- externer PostgreSQL-Server
|
||||
- externer S3-Speicher
|
||||
- externe Zertifikatsverwaltung
|
||||
|
||||
dann konnen `traefik`, `db` und `minio` aus dem Stack entfernt werden. In diesem Fall mussen die zugehorigen Hostnamen und Zugangsdaten in der `.env` beziehungsweise im Frontend-Environment auf die externe Infrastruktur zeigen.
|
||||
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules
|
||||
.env
|
||||
|
||||
/src/generated/prisma
|
||||
/dist/
|
||||
|
||||
3
backend/.secretlintrc.json
Normal file
3
backend/.secretlintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rules": []
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
poppler-utils \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-deu \
|
||||
tesseract-ocr-eng \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Package-Dateien
|
||||
COPY package*.json ./
|
||||
|
||||
# Dev + Prod Dependencies (für TS-Build nötig)
|
||||
RUN npm install
|
||||
# Dev + Prod Dependencies (für TS-Build nötig).
|
||||
# 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
|
||||
COPY . .
|
||||
@@ -16,5 +26,5 @@ RUN npm run build
|
||||
# Port freigeben
|
||||
EXPOSE 3100
|
||||
|
||||
# Start der App
|
||||
CMD ["node", "dist/src/index.js"]
|
||||
# Migrationen ausführen und App starten
|
||||
CMD ["sh", "./docker-entrypoint.sh"]
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { Pool } from "pg"
|
||||
// src/db/index.ts
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "./schema";
|
||||
import {secrets} from "../src/utils/secrets";
|
||||
import * as schema from "./schema"
|
||||
|
||||
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
|
||||
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
|
||||
if (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 {
|
||||
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
|
||||
}
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: secrets.DATABASE_URL,
|
||||
max: 10, // je nach Last
|
||||
})
|
||||
connectionString,
|
||||
max: 10,
|
||||
});
|
||||
|
||||
export const db = drizzle(pool , {schema})
|
||||
// TEST: Ist die DB wirklich da?
|
||||
pool.query('SELECT 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));
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
2
backend/db/migrations/0003_woozy_adam_destine.sql
Normal file
2
backend/db/migrations/0003_woozy_adam_destine.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- No-op migration: Datei war im Journal referenziert, aber fehlte im Repository.
|
||||
SELECT 1;
|
||||
2
backend/db/migrations/0004_stormy_onslaught.sql
Normal file
2
backend/db/migrations/0004_stormy_onslaught.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- No-op migration: Datei war im Journal referenziert, aber fehlte im Repository.
|
||||
SELECT 1;
|
||||
95
backend/db/migrations/0005_green_shinobi_shaw.sql
Normal file
95
backend/db/migrations/0005_green_shinobi_shaw.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
CREATE TABLE "m2m_api_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant_id" bigint NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"created_by" uuid,
|
||||
"name" text NOT NULL,
|
||||
"key_prefix" text NOT NULL,
|
||||
"key_hash" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone,
|
||||
CONSTRAINT "m2m_api_keys_key_hash_unique" UNIQUE("key_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
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),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"intervall" text,
|
||||
"icon" text,
|
||||
"tenant" bigint NOT NULL,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "serial_executions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant" bigint NOT NULL,
|
||||
"execution_date" timestamp NOT NULL,
|
||||
"status" text DEFAULT 'draft',
|
||||
"created_by" text,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"summary" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "public_links" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"tenant" integer NOT NULL,
|
||||
"default_profile" uuid,
|
||||
"is_protected" boolean DEFAULT false NOT NULL,
|
||||
"pin_hash" text,
|
||||
"config" jsonb DEFAULT '{}'::jsonb,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "public_links_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "wiki_pages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" bigint NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"title" text NOT NULL,
|
||||
"content" jsonb,
|
||||
"is_folder" boolean DEFAULT false NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"entity_type" text,
|
||||
"entity_id" bigint,
|
||||
"entity_uuid" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> 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 "devices" ADD COLUMN "last_seen" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "devices" ADD COLUMN "last_debug_info" jsonb;--> 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_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 "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_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 "serial_executions" ADD CONSTRAINT "serial_executions_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "public_links" ADD CONSTRAINT "public_links_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "public_links" ADD CONSTRAINT "public_links_default_profile_auth_profiles_id_fk" FOREIGN KEY ("default_profile") REFERENCES "public"."auth_profiles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("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_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 "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_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
|
||||
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
backend/db/migrations/0006_nifty_price_lock.sql
Normal file
1
backend/db/migrations/0006_nifty_price_lock.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;
|
||||
1
backend/db/migrations/0007_bright_default_tax_type.sql
Normal file
1
backend/db/migrations/0007_bright_default_tax_type.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;
|
||||
16
backend/db/migrations/0008_quick_contracttypes.sql
Normal file
16
backend/db/migrations/0008_quick_contracttypes.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "contracttypes" (
|
||||
"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,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;
|
||||
--> 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;
|
||||
3
backend/db/migrations/0010_sudden_billing_interval.sql
Normal file
3
backend/db/migrations/0010_sudden_billing_interval.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "contracttypes" ADD COLUMN "billingInterval" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;
|
||||
16
backend/db/migrations/0011_mighty_member_bankaccounts.sql
Normal file
16
backend/db/migrations/0011_mighty_member_bankaccounts.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
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
|
||||
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;
|
||||
73
backend/db/migrations/0012_shiny_customer_inventory.sql
Normal file
73
backend/db/migrations/0012_shiny_customer_inventory.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
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 "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,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> 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 "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_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 UNIQUE INDEX "customerinventoryitems_tenant_customerInventoryId_idx" ON "customerinventoryitems" USING btree ("tenant","customerInventoryId");
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;
|
||||
--> 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 "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
|
||||
UPDATE "tenants"
|
||||
SET "numberRanges" = COALESCE("numberRanges", '{}'::jsonb) || jsonb_build_object(
|
||||
'customerspaces', COALESCE("numberRanges"->'customerspaces', '{"prefix":"KLP-","suffix":"","nextNumber":1000}'::jsonb),
|
||||
'customerinventoryitems', COALESCE("numberRanges"->'customerinventoryitems', '{"prefix":"KIA-","suffix":"","nextNumber":1000}'::jsonb)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "customerinventoryitems" ADD COLUMN "vendor" bigint;
|
||||
--> 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;
|
||||
20
backend/db/migrations/0014_smart_memberrelations.sql
Normal file
20
backend/db/migrations/0014_smart_memberrelations.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
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 "customers" ADD COLUMN "memberrelation" bigint;
|
||||
--> 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 "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;
|
||||
--> 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;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "memberrelation" bigint;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'customers_memberrelation_memberrelations_id_fk'
|
||||
) THEN
|
||||
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;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
UPDATE "customers"
|
||||
SET "memberrelation" = ("infoData"->>'memberrelation')::bigint
|
||||
WHERE
|
||||
"memberrelation" IS NULL
|
||||
AND "type" = 'Mitglied'
|
||||
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
|
||||
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation'
|
||||
AND ("infoData"->>'memberrelation') ~ '^[0-9]+$';
|
||||
|
||||
UPDATE "customers"
|
||||
SET "infoData" = COALESCE("infoData", '{}'::jsonb) - 'memberrelation'
|
||||
WHERE
|
||||
"type" = 'Mitglied'
|
||||
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
|
||||
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation';
|
||||
1
backend/db/migrations/0017_slow_the_hood.sql
Normal file
1
backend/db/migrations/0017_slow_the_hood.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Absichtlich leer: Die Objekte aus dieser generierten Migration existieren bereits in früheren Migrationen.
|
||||
3
backend/db/migrations/0018_account_chart.sql
Normal file
3
backend/db/migrations/0018_account_chart.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "accounts" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "createddocuments"
|
||||
ALTER COLUMN "customSurchargePercentage" TYPE double precision
|
||||
USING "customSurchargePercentage"::double precision;
|
||||
1
backend/db/migrations/0020_file_extracted_text.sql
Normal file
1
backend/db/migrations/0020_file_extracted_text.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "files" ADD COLUMN "extracted_text" text;
|
||||
2
backend/db/migrations/0021_admin_user_flag.sql
Normal file
2
backend/db/migrations/0021_admin_user_flag.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "auth_users"
|
||||
ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;
|
||||
2
backend/db/migrations/0022_task_dependencies.sql
Normal file
2
backend/db/migrations/0022_task_dependencies.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tasks"
|
||||
ADD COLUMN "dependency_ids" jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
2
backend/db/migrations/0023_tax_evaluation_period.sql
Normal file
2
backend/db/migrations/0023_tax_evaluation_period.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tenants"
|
||||
ADD COLUMN "taxEvaluationPeriod" text DEFAULT 'monthly' NOT NULL;
|
||||
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;
|
||||
10685
backend/db/migrations/meta/0005_snapshot.json
Normal file
10685
backend/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11525
backend/db/migrations/meta/0017_snapshot.json
Normal file
11525
backend/db/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
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
@@ -36,6 +36,237 @@
|
||||
"when": 1765716877146,
|
||||
"tag": "0004_stormy_onslaught",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1771096926109,
|
||||
"tag": "0005_green_shinobi_shaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1772000000000,
|
||||
"tag": "0006_nifty_price_lock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1772000100000,
|
||||
"tag": "0007_bright_default_tax_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1773000000000,
|
||||
"tag": "0008_quick_contracttypes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1773000100000,
|
||||
"tag": "0009_heavy_contract_contracttype",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1773000200000,
|
||||
"tag": "0010_sudden_billing_interval",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1773000300000,
|
||||
"tag": "0011_mighty_member_bankaccounts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1773000400000,
|
||||
"tag": "0012_shiny_customer_inventory",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1773000500000,
|
||||
"tag": "0013_brisk_customer_inventory_vendor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1773000600000,
|
||||
"tag": "0014_smart_memberrelations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1773000700000,
|
||||
"tag": "0015_wise_memberrelation_history",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1773000800000,
|
||||
"tag": "0016_fix_memberrelation_column_usage",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1771704862789,
|
||||
"tag": "0017_slow_the_hood",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1773000900000,
|
||||
"tag": "0018_account_chart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1773489600000,
|
||||
"tag": "0019_custom_surcharge_percentage_decimal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1773572400000,
|
||||
"tag": "0020_file_extracted_text",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1773835200000,
|
||||
"tag": "0021_admin_user_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1773925200000,
|
||||
"tag": "0022_task_dependencies",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1774080000000,
|
||||
"tag": "0023_tax_evaluation_period",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const accounts = pgTable("accounts", {
|
||||
|
||||
number: text("number").notNull(),
|
||||
label: text("label").notNull(),
|
||||
accountChart: text("accountChart").notNull().default("skr03"),
|
||||
|
||||
description: text("description"),
|
||||
})
|
||||
|
||||
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,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { branches } from "./branches"
|
||||
|
||||
export const authProfiles = pgTable("auth_profiles", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", {
|
||||
|
||||
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 })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
@@ -71,6 +74,7 @@ export const authProfiles = pgTable("auth_profiles", {
|
||||
contract_type: text("contract_type"),
|
||||
position: text("position"),
|
||||
qualification: text("qualification"),
|
||||
availability_note: text("availability_note"),
|
||||
|
||||
address_street: text("address_street"),
|
||||
address_zip: text("address_zip"),
|
||||
|
||||
@@ -12,6 +12,7 @@ export const authUsers = pgTable("auth_users", {
|
||||
|
||||
multiTenant: boolean("multi_tenant").notNull().default(true),
|
||||
must_change_password: boolean("must_change_password").notNull().default(false),
|
||||
is_admin: boolean("is_admin").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
|
||||
|
||||
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
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { tenants } from "./tenants"
|
||||
import { customers } from "./customers"
|
||||
import { contacts } from "./contacts"
|
||||
import { contracttypes } from "./contracttypes"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { outgoingsepamandates } from "./outgoingsepamandates"
|
||||
|
||||
export const contracts = pgTable(
|
||||
"contracts",
|
||||
@@ -48,6 +50,10 @@ export const contracts = pgTable(
|
||||
contact: bigint("contact", { mode: "number" }).references(
|
||||
() => contacts.id
|
||||
),
|
||||
contracttype: bigint("contracttype", { mode: "number" }).references(
|
||||
() => contracttypes.id
|
||||
),
|
||||
allowedContracttypes: jsonb("allowedContracttypes").notNull().default([]),
|
||||
|
||||
bankingIban: text("bankingIban"),
|
||||
bankingBIC: text("bankingBIC"),
|
||||
@@ -55,8 +61,12 @@ export const contracts = pgTable(
|
||||
bankingOwner: text("bankingOwner"),
|
||||
sepaRef: text("sepaRef"),
|
||||
sepaDate: timestamp("sepaDate", { withTimezone: true }),
|
||||
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||
() => outgoingsepamandates.id
|
||||
),
|
||||
|
||||
paymentType: text("paymentType"),
|
||||
billingInterval: text("billingInterval"),
|
||||
invoiceDispatch: text("invoiceDispatch"),
|
||||
|
||||
ownFields: jsonb("ownFields").notNull().default({}),
|
||||
|
||||
40
backend/db/schema/contracttypes.ts
Normal file
40
backend/db/schema/contracttypes.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"
|
||||
|
||||
export const contracttypes = pgTable("contracttypes", {
|
||||
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"),
|
||||
|
||||
paymentType: text("paymentType"),
|
||||
recurring: boolean("recurring").notNull().default(false),
|
||||
billingInterval: text("billingInterval"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type ContractType = typeof contracttypes.$inferSelect
|
||||
export type NewContractType = typeof contracttypes.$inferInsert
|
||||
@@ -13,6 +13,7 @@ import { inventoryitems } from "./inventoryitems"
|
||||
import { projects } from "./projects"
|
||||
import { vehicles } from "./vehicles"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { branches } from "./branches"
|
||||
|
||||
export const costcentres = pgTable("costcentres", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -28,10 +29,14 @@ export const costcentres = pgTable("costcentres", {
|
||||
number: text("number").notNull(),
|
||||
name: text("name").notNull(),
|
||||
|
||||
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
|
||||
|
||||
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
|
||||
|
||||
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
||||
|
||||
branch: bigint("branch", { mode: "number" }).references(() => branches.id),
|
||||
|
||||
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
|
||||
() => inventoryitems.id
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
jsonb,
|
||||
boolean,
|
||||
smallint,
|
||||
doublePrecision,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
@@ -18,6 +19,7 @@ import { projects } from "./projects"
|
||||
import { plants } from "./plants"
|
||||
import { authUsers } from "./auth_users"
|
||||
import {serialExecutions} from "./serialexecutions";
|
||||
import { outgoingsepamandates } from "./outgoingsepamandates"
|
||||
|
||||
export const createddocuments = pgTable("createddocuments", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -96,7 +98,7 @@ export const createddocuments = pgTable("createddocuments", {
|
||||
|
||||
taxType: text("taxType"),
|
||||
|
||||
customSurchargePercentage: smallint("customSurchargePercentage")
|
||||
customSurchargePercentage: doublePrecision("customSurchargePercentage")
|
||||
.notNull()
|
||||
.default(0),
|
||||
|
||||
@@ -117,6 +119,10 @@ export const createddocuments = pgTable("createddocuments", {
|
||||
() => contracts.id
|
||||
),
|
||||
|
||||
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||
() => outgoingsepamandates.id
|
||||
),
|
||||
|
||||
serialexecution: uuid("serialexecution").references(() => serialExecutions.id)
|
||||
})
|
||||
|
||||
|
||||
66
backend/db/schema/customerinventoryitems.ts
Normal file
66
backend/db/schema/customerinventoryitems.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
boolean,
|
||||
doublePrecision,
|
||||
uuid,
|
||||
date,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { customers } from "./customers"
|
||||
import { customerspaces } from "./customerspaces"
|
||||
import { products } from "./products"
|
||||
import { vendors } from "./vendors"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const customerinventoryitems = pgTable("customerinventoryitems", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
name: text("name").notNull(),
|
||||
|
||||
description: text("description"),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
customer: bigint("customer", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => customers.id),
|
||||
|
||||
customerspace: bigint("customerspace", { mode: "number" }).references(
|
||||
() => customerspaces.id
|
||||
),
|
||||
|
||||
customerInventoryId: text("customerInventoryId").notNull(),
|
||||
serialNumber: text("serialNumber"),
|
||||
|
||||
quantity: bigint("quantity", { mode: "number" }).notNull().default(0),
|
||||
|
||||
manufacturer: text("manufacturer"),
|
||||
manufacturerNumber: text("manufacturerNumber"),
|
||||
|
||||
purchaseDate: date("purchaseDate"),
|
||||
purchasePrice: doublePrecision("purchasePrice").default(0),
|
||||
currentValue: doublePrecision("currentValue"),
|
||||
|
||||
product: bigint("product", { mode: "number" }).references(() => products.id),
|
||||
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type CustomerInventoryItem = typeof customerinventoryitems.$inferSelect
|
||||
export type NewCustomerInventoryItem = typeof customerinventoryitems.$inferInsert
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { memberrelations } from "./memberrelations"
|
||||
|
||||
export const customers = pgTable(
|
||||
"customers",
|
||||
@@ -62,6 +63,8 @@ export const customers = pgTable(
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
|
||||
customPaymentType: text("custom_payment_type"), // ENUM payment_types separat?
|
||||
customTaxType: text("customTaxType"),
|
||||
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
54
backend/db/schema/customerspaces.ts
Normal file
54
backend/db/schema/customerspaces.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
boolean,
|
||||
jsonb,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { customers } from "./customers"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const customerspaces = pgTable("customerspaces", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
customer: bigint("customer", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => customers.id),
|
||||
|
||||
space_number: text("spaceNumber").notNull(),
|
||||
|
||||
parentSpace: bigint("parentSpace", { mode: "number" }).references(
|
||||
() => customerspaces.id
|
||||
),
|
||||
|
||||
info_data: jsonb("infoData")
|
||||
.notNull()
|
||||
.default({ zip: "", city: "", streetNumber: "" }),
|
||||
|
||||
description: text("description"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type CustomerSpace = typeof customerspaces.$inferSelect
|
||||
export type NewCustomerSpace = typeof customerspaces.$inferInsert
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
uuid,
|
||||
timestamp,
|
||||
text,
|
||||
bigint,
|
||||
bigint, jsonb,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
@@ -23,6 +23,11 @@ export const devices = pgTable("devices", {
|
||||
password: text("password"),
|
||||
|
||||
externalId: text("externalId"),
|
||||
|
||||
lastSeen: timestamp("last_seen", { withTimezone: true }),
|
||||
|
||||
// Hier speichern wir den ganzen Payload (RSSI, Heap, IP, etc.)
|
||||
lastDebugInfo: jsonb("last_debug_info"),
|
||||
})
|
||||
|
||||
export type Device = typeof devices.$inferSelect
|
||||
|
||||
39
backend/db/schema/entitybankaccounts.ts
Normal file
39
backend/db/schema/entitybankaccounts.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
boolean,
|
||||
jsonb,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const entitybankaccounts = pgTable("entitybankaccounts", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
ibanEncrypted: jsonb("iban_encrypted").notNull(),
|
||||
bicEncrypted: jsonb("bic_encrypted").notNull(),
|
||||
bankNameEncrypted: jsonb("bank_name_encrypted").notNull(),
|
||||
|
||||
description: text("description"),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
})
|
||||
|
||||
export type EntityBankAccount = typeof entitybankaccounts.$inferSelect
|
||||
export type NewEntityBankAccount = typeof entitybankaccounts.$inferInsert
|
||||
@@ -31,6 +31,7 @@ export const events = pgTable(
|
||||
endDate: timestamp("endDate", { withTimezone: true }),
|
||||
|
||||
eventtype: text("eventtype").default("Umsetzung"),
|
||||
quick: boolean("quick").notNull().default(false),
|
||||
|
||||
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export const files = pgTable("files", {
|
||||
documentbox: uuid("documentbox").references(() => documentboxes.id),
|
||||
|
||||
name: text("name"),
|
||||
extractedText: text("extracted_text"),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
@@ -73,6 +74,7 @@ export const files = pgTable("files", {
|
||||
createdBy: uuid("created_by").references(() => authUsers.id),
|
||||
|
||||
authProfile: uuid("auth_profile").references(() => authProfiles.id),
|
||||
size: bigint("size", { mode: "number" }),
|
||||
})
|
||||
|
||||
export type File = typeof files.$inferSelect
|
||||
|
||||
@@ -20,6 +20,8 @@ import { tasks } from "./tasks"
|
||||
import { vehicles } from "./vehicles"
|
||||
import { bankstatements } from "./bankstatements"
|
||||
import { spaces } from "./spaces"
|
||||
import { customerspaces } from "./customerspaces"
|
||||
import { customerinventoryitems } from "./customerinventoryitems"
|
||||
import { costcentres } from "./costcentres"
|
||||
import { ownaccounts } from "./ownaccounts"
|
||||
import { createddocuments } from "./createddocuments"
|
||||
@@ -32,6 +34,9 @@ import { events } from "./events"
|
||||
import { inventoryitemgroups } from "./inventoryitemgroups"
|
||||
import { authUsers } from "./auth_users"
|
||||
import {files} from "./files";
|
||||
import { memberrelations } from "./memberrelations";
|
||||
import { contracts } from "./contracts";
|
||||
import { outgoingsepamandates } from "./outgoingsepamandates";
|
||||
|
||||
export const historyitems = pgTable("historyitems", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -49,6 +54,11 @@ export const historyitems = pgTable("historyitems", {
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
|
||||
contract: bigint("contract", { mode: "number" }).references(
|
||||
() => contracts.id,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
@@ -99,6 +109,17 @@ export const historyitems = pgTable("historyitems", {
|
||||
|
||||
space: bigint("space", { mode: "number" }).references(() => spaces.id),
|
||||
|
||||
customerspace: bigint("customerspace", { mode: "number" }).references(() => customerspaces.id),
|
||||
|
||||
customerinventoryitem: bigint("customerinventoryitem", { mode: "number" }).references(() => customerinventoryitems.id),
|
||||
|
||||
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
|
||||
|
||||
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||
() => outgoingsepamandates.id,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
|
||||
config: jsonb("config"),
|
||||
|
||||
projecttype: bigint("projecttype", { mode: "number" }).references(
|
||||
|
||||
@@ -14,7 +14,7 @@ export const hourrates = pgTable("hourrates", {
|
||||
|
||||
name: text("name").notNull(),
|
||||
|
||||
purchasePrice: doublePrecision("purchasePrice").notNull(),
|
||||
purchase_price: doublePrecision("purchasePrice").notNull(),
|
||||
sellingPrice: doublePrecision("sellingPrice").notNull(),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from "./accounts"
|
||||
export * from "./auth_profiles"
|
||||
export * from "./auth_profile_branches"
|
||||
export * from "./auth_profile_teams"
|
||||
export * from "./auth_role_permisssions"
|
||||
export * from "./auth_roles"
|
||||
export * from "./auth_tenant_users"
|
||||
@@ -8,20 +10,26 @@ export * from "./auth_users"
|
||||
export * from "./bankaccounts"
|
||||
export * from "./bankrequisitions"
|
||||
export * from "./bankstatements"
|
||||
export * from "./branches"
|
||||
export * from "./checkexecutions"
|
||||
export * from "./checks"
|
||||
export * from "./citys"
|
||||
export * from "./communication_rooms"
|
||||
export * from "./contacts"
|
||||
export * from "./contracts"
|
||||
export * from "./contracttypes"
|
||||
export * from "./costcentres"
|
||||
export * from "./countrys"
|
||||
export * from "./createddocuments"
|
||||
export * from "./createdletters"
|
||||
export * from "./customers"
|
||||
export * from "./customerspaces"
|
||||
export * from "./customerinventoryitems"
|
||||
export * from "./devices"
|
||||
export * from "./documentboxes"
|
||||
export * from "./enums"
|
||||
export * from "./events"
|
||||
export * from "./entitybankaccounts"
|
||||
export * from "./files"
|
||||
export * from "./filetags"
|
||||
export * from "./folders"
|
||||
@@ -42,12 +50,16 @@ export * from "./incominginvoices"
|
||||
export * from "./inventoryitemgroups"
|
||||
export * from "./inventoryitems"
|
||||
export * from "./letterheads"
|
||||
export * from "./memberrelations"
|
||||
export * from "./movements"
|
||||
export * from "./m2m_api_keys"
|
||||
export * from "./notifications_event_types"
|
||||
export * from "./notifications_items"
|
||||
export * from "./notifications_preferences"
|
||||
export * from "./notifications_preferences_defaults"
|
||||
export * from "./notification_push_subscriptions"
|
||||
export * from "./ownaccounts"
|
||||
export * from "./outgoingsepamandates"
|
||||
export * from "./plants"
|
||||
export * from "./productcategories"
|
||||
export * from "./products"
|
||||
@@ -61,6 +73,7 @@ export * from "./staff_time_entry_connects"
|
||||
export * from "./staff_zeitstromtimestamps"
|
||||
export * from "./statementallocations"
|
||||
export * from "./tasks"
|
||||
export * from "./teams"
|
||||
export * from "./taxtypes"
|
||||
export * from "./tenants"
|
||||
export * from "./texttemplates"
|
||||
@@ -71,4 +84,5 @@ export * from "./vendors"
|
||||
export * from "./staff_time_events"
|
||||
export * from "./serialtypes"
|
||||
export * from "./serialexecutions"
|
||||
export * from "./public_links"
|
||||
export * from "./public_links"
|
||||
export * from "./wikipages"
|
||||
|
||||
48
backend/db/schema/m2m_api_keys.ts
Normal file
48
backend/db/schema/m2m_api_keys.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
bigint,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const m2mApiKeys = pgTable("m2m_api_keys", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||
|
||||
createdBy: uuid("created_by").references(() => authUsers.id, {
|
||||
onDelete: "set null",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
|
||||
name: text("name").notNull(),
|
||||
keyPrefix: text("key_prefix").notNull(),
|
||||
keyHash: text("key_hash").notNull().unique(),
|
||||
|
||||
active: boolean("active").notNull().default(true),
|
||||
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
})
|
||||
|
||||
export type M2mApiKey = typeof m2mApiKeys.$inferSelect
|
||||
export type NewM2mApiKey = typeof m2mApiKeys.$inferInsert
|
||||
39
backend/db/schema/memberrelations.ts
Normal file
39
backend/db/schema/memberrelations.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
boolean,
|
||||
uuid,
|
||||
doublePrecision,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const memberrelations = pgTable("memberrelations", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
type: text("type").notNull(),
|
||||
billingInterval: text("billingInterval").notNull(),
|
||||
billingAmount: doublePrecision("billingAmount").notNull().default(0),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type MemberRelation = typeof memberrelations.$inferSelect
|
||||
export type NewMemberRelation = typeof memberrelations.$inferInsert
|
||||
|
||||
50
backend/db/schema/notification_push_subscriptions.ts
Normal file
50
backend/db/schema/notification_push_subscriptions.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
bigint,
|
||||
text,
|
||||
jsonb,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const notificationPushSubscriptions = pgTable(
|
||||
"notification_push_subscriptions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||
|
||||
endpoint: text("endpoint").notNull(),
|
||||
p256dh: text("p256dh").notNull(),
|
||||
auth: text("auth").notNull(),
|
||||
userAgent: text("user_agent"),
|
||||
deviceLabel: text("device_label"),
|
||||
meta: jsonb("meta"),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
uniqueEndpoint: uniqueIndex("notification_push_subscriptions_endpoint_key").on(table.endpoint),
|
||||
}),
|
||||
)
|
||||
|
||||
export type NotificationPushSubscription =
|
||||
typeof notificationPushSubscriptions.$inferSelect
|
||||
export type NewNotificationPushSubscription =
|
||||
typeof notificationPushSubscriptions.$inferInsert
|
||||
61
backend/db/schema/outgoingsepamandates.ts
Normal file
61
backend/db/schema/outgoingsepamandates.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { customers } from "./customers"
|
||||
import { entitybankaccounts } from "./entitybankaccounts"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const outgoingsepamandates = pgTable("outgoingsepamandates", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
customer: bigint("customer", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => customers.id),
|
||||
|
||||
bankaccount: bigint("bankaccount", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => entitybankaccounts.id),
|
||||
|
||||
reference: text("reference").notNull(),
|
||||
|
||||
status: text("status").notNull().default("Entwurf"),
|
||||
|
||||
mandateType: text("mandate_type").notNull().default("CORE"),
|
||||
|
||||
sequenceType: text("sequence_type").notNull().default("RCUR"),
|
||||
|
||||
signedAt: timestamp("signed_at", { withTimezone: true }),
|
||||
|
||||
validFrom: timestamp("valid_from", { withTimezone: true }),
|
||||
|
||||
validUntil: timestamp("valid_until", { withTimezone: true }),
|
||||
|
||||
defaultMandate: boolean("default_mandate").notNull().default(false),
|
||||
|
||||
notes: text("notes"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type OutgoingSepaMandate = typeof outgoingsepamandates.$inferSelect
|
||||
export type NewOutgoingSepaMandate = typeof outgoingsepamandates.$inferInsert
|
||||
@@ -50,6 +50,7 @@ export const products = pgTable("products", {
|
||||
vendor_allocation: jsonb("vendorAllocation").default([]),
|
||||
|
||||
article_number: text("articleNumber"),
|
||||
supplier_link: text("supplierLink"),
|
||||
|
||||
barcodes: text("barcodes").array().notNull().default([]),
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const services = pgTable("services", {
|
||||
|
||||
materialComposition: jsonb("materialComposition").notNull().default([]),
|
||||
personalComposition: jsonb("personalComposition").notNull().default([]),
|
||||
priceUpdateLocked: boolean("priceUpdateLocked").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
|
||||
@@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
// foreign keys
|
||||
bankstatement: integer("bs_id")
|
||||
.notNull()
|
||||
.references(() => bankstatements.id),
|
||||
bankstatement: integer("bs_id").references(() => bankstatements.id),
|
||||
|
||||
createddocument: integer("cd_id").references(() => createddocuments.id),
|
||||
|
||||
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
||||
() => incominginvoices.id
|
||||
),
|
||||
manualInvoiceSide: text("manual_invoice_side"),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
@@ -43,20 +42,43 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
() => accounts.id
|
||||
),
|
||||
|
||||
contraAccount: bigint("contra_account", { mode: "number" }).references(
|
||||
() => accounts.id
|
||||
),
|
||||
|
||||
created_at: timestamp("created_at", {
|
||||
withTimezone: false,
|
||||
}).defaultNow(),
|
||||
|
||||
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
|
||||
|
||||
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
|
||||
|
||||
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(
|
||||
() => customers.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_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
|
||||
@@ -74,6 +74,51 @@ export const tenants = pgTable(
|
||||
timeTracking: true,
|
||||
planningBoard: true,
|
||||
workingTimeTracking: true,
|
||||
dashboard: true,
|
||||
historyitems: true,
|
||||
tasks: true,
|
||||
wiki: true,
|
||||
files: true,
|
||||
createdletters: true,
|
||||
documentboxes: true,
|
||||
helpdesk: true,
|
||||
email: true,
|
||||
members: true,
|
||||
customers: true,
|
||||
vendors: true,
|
||||
contactsList: true,
|
||||
staffTime: true,
|
||||
createDocument: true,
|
||||
serialInvoice: true,
|
||||
incomingInvoices: true,
|
||||
outgoingsepamandates: true,
|
||||
costcentres: true,
|
||||
branches: true,
|
||||
teams: true,
|
||||
accounts: true,
|
||||
ownaccounts: true,
|
||||
banking: true,
|
||||
spaces: true,
|
||||
customerspaces: true,
|
||||
customerinventoryitems: true,
|
||||
inventoryitems: true,
|
||||
inventoryitemgroups: true,
|
||||
products: true,
|
||||
productcategories: true,
|
||||
services: true,
|
||||
servicecategories: true,
|
||||
memberrelations: true,
|
||||
staffProfiles: true,
|
||||
hourrates: true,
|
||||
projecttypes: true,
|
||||
contracttypes: true,
|
||||
plants: true,
|
||||
settingsNumberRanges: true,
|
||||
settingsEmailAccounts: true,
|
||||
settingsBanking: true,
|
||||
settingsTexttemplates: true,
|
||||
settingsTenant: true,
|
||||
export: true,
|
||||
}),
|
||||
|
||||
ownFields: jsonb("ownFields"),
|
||||
@@ -85,13 +130,20 @@ export const tenants = pgTable(
|
||||
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 },
|
||||
}),
|
||||
accountChart: text("accountChart").notNull().default("skr03"),
|
||||
|
||||
standardEmailForInvoices: text("standardEmailForInvoices"),
|
||||
|
||||
@@ -116,6 +168,10 @@ export const tenants = pgTable(
|
||||
.notNull()
|
||||
.default(14),
|
||||
|
||||
taxEvaluationPeriod: text("taxEvaluationPeriod")
|
||||
.notNull()
|
||||
.default("monthly"),
|
||||
|
||||
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
|
||||
|
||||
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(),
|
||||
|
||||
99
backend/db/schema/wikipages.ts
Normal file
99
backend/db/schema/wikipages.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
jsonb,
|
||||
integer,
|
||||
index,
|
||||
uuid,
|
||||
AnyPgColumn
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { relations } from "drizzle-orm"
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const wikiPages = pgTable(
|
||||
"wiki_pages",
|
||||
{
|
||||
// ID des Wiki-Eintrags selbst (neu = UUID)
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.defaultRandom(),
|
||||
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
|
||||
parentId: uuid("parent_id")
|
||||
.references((): AnyPgColumn => wikiPages.id, { onDelete: "cascade" }),
|
||||
|
||||
title: text("title").notNull(),
|
||||
|
||||
content: jsonb("content"),
|
||||
|
||||
isFolder: boolean("is_folder").notNull().default(false),
|
||||
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
|
||||
// --- POLYMORPHE BEZIEHUNG (Split) ---
|
||||
|
||||
// Art der Entität (z.B. 'customer', 'invoice', 'iot_device')
|
||||
entityType: text("entity_type"),
|
||||
|
||||
// SPALTE 1: Für Legacy-Tabellen (BigInt)
|
||||
// Nutzung: Wenn entityType='customer', wird hier die ID 1050 gespeichert
|
||||
entityId: bigint("entity_id", { mode: "number" }),
|
||||
|
||||
// SPALTE 2: Für neue Tabellen (UUID)
|
||||
// Nutzung: Wenn entityType='iot_device', wird hier die UUID gespeichert
|
||||
entityUuid: uuid("entity_uuid"),
|
||||
|
||||
// ------------------------------------
|
||||
|
||||
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) => ({
|
||||
tenantIdx: index("wiki_pages_tenant_idx").on(table.tenantId),
|
||||
parentIdx: index("wiki_pages_parent_idx").on(table.parentId),
|
||||
|
||||
// ZWEI separate Indexe für schnelle Lookups, je nachdem welche ID genutzt wird
|
||||
// Fall 1: Suche nach Notizen für Kunde 1050
|
||||
entityIntIdx: index("wiki_pages_entity_int_idx")
|
||||
.on(table.tenantId, table.entityType, table.entityId),
|
||||
|
||||
// Fall 2: Suche nach Notizen für IoT-Device 550e84...
|
||||
entityUuidIdx: index("wiki_pages_entity_uuid_idx")
|
||||
.on(table.tenantId, table.entityType, table.entityUuid),
|
||||
})
|
||||
)
|
||||
|
||||
export const wikiPagesRelations = relations(wikiPages, ({ one, many }) => ({
|
||||
tenant: one(tenants, {
|
||||
fields: [wikiPages.tenantId],
|
||||
references: [tenants.id],
|
||||
}),
|
||||
parent: one(wikiPages, {
|
||||
fields: [wikiPages.parentId],
|
||||
references: [wikiPages.id],
|
||||
relationName: "parent_child",
|
||||
}),
|
||||
children: many(wikiPages, {
|
||||
relationName: "parent_child",
|
||||
}),
|
||||
author: one(authUsers, {
|
||||
fields: [wikiPages.createdBy],
|
||||
references: [authUsers.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export type WikiPage = typeof wikiPages.$inferSelect
|
||||
export type NewWikiPage = typeof wikiPages.$inferInsert
|
||||
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 {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({
|
||||
dialect: "postgresql",
|
||||
schema: "./db/schema",
|
||||
out: "./db/migrations",
|
||||
dbCredentials: {
|
||||
url: secrets.DATABASE_URL,
|
||||
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,9 +5,16 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"migrate": "tsx scripts/migrate.ts",
|
||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/src/index.js",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -27,7 +34,6 @@
|
||||
"@infisical/sdk": "^4.0.6",
|
||||
"@mmote/niimbluelib": "^0.0.1-alpha.29",
|
||||
"@prisma/client": "^6.15.0",
|
||||
"@supabase/supabase-js": "^2.56.1",
|
||||
"@zip.js/zip.js": "^2.7.73",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.12.1",
|
||||
@@ -48,6 +54,8 @@
|
||||
"pg": "^8.16.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"web-push": "^3.6.7",
|
||||
"webdav-server": "^2.6.2",
|
||||
"xmlbuilder": "^15.1.1",
|
||||
"zpl-image": "^0.2.0",
|
||||
"zpl-renderer-js": "^2.0.2"
|
||||
@@ -56,6 +64,7 @@
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"prisma": "^6.15.0",
|
||||
"tsx": "^4.20.5",
|
||||
|
||||
95
backend/scripts/generate-de-bank-codes.ts
Normal file
95
backend/scripts/generate-de-bank-codes.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import https from "node:https"
|
||||
|
||||
const DEFAULT_SOURCE_URL =
|
||||
"https://www.bundesbank.de/resource/blob/602632/bec25ca5df1eb62fefadd8325dafe67c/472B63F073F071307366337C94F8C870/blz-aktuell-txt-data.txt"
|
||||
|
||||
const OUTPUT_NAME_FILE = path.resolve("src/utils/deBankCodes.ts")
|
||||
const OUTPUT_BIC_FILE = path.resolve("src/utils/deBankBics.ts")
|
||||
|
||||
function fetchBuffer(url: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, (res) => {
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return resolve(fetchBuffer(res.headers.location))
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`Download failed with status ${res.statusCode}`))
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)))
|
||||
res.on("error", reject)
|
||||
})
|
||||
.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function escapeTsString(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const source = process.env.BLZ_SOURCE_URL || DEFAULT_SOURCE_URL
|
||||
const sourceFile = process.env.BLZ_SOURCE_FILE
|
||||
let raw: Buffer
|
||||
|
||||
if (sourceFile) {
|
||||
console.log(`Reading BLZ source file: ${sourceFile}`)
|
||||
raw = await fs.readFile(sourceFile)
|
||||
} else {
|
||||
console.log(`Downloading BLZ source: ${source}`)
|
||||
raw = await fetchBuffer(source)
|
||||
}
|
||||
const content = raw.toString("latin1")
|
||||
|
||||
const lines = content.split(/\r?\n/)
|
||||
const nameMap = new Map<string, string>()
|
||||
const bicMap = new Map<string, string>()
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line || line.length < 150) continue
|
||||
const blz = line.slice(0, 8).trim()
|
||||
const name = line.slice(9, 67).trim()
|
||||
const bic = line.slice(139, 150).trim()
|
||||
|
||||
if (!/^\d{8}$/.test(blz) || !name) continue
|
||||
if (!nameMap.has(blz)) nameMap.set(blz, name)
|
||||
if (bic && !bicMap.has(blz)) bicMap.set(blz, bic)
|
||||
}
|
||||
|
||||
const sortedNames = [...nameMap.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
const sortedBics = [...bicMap.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
const nameOutputLines = [
|
||||
"// Lokale Bankleitzahl-zu-Institut Zuordnung (DE).",
|
||||
"// Quelle: Deutsche Bundesbank, BLZ-Datei (vollstaendig).",
|
||||
"export const DE_BANK_CODE_TO_NAME: Record<string, string> = {",
|
||||
...sortedNames.map(([blz, name]) => ` "${blz}": "${escapeTsString(name)}",`),
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
|
||||
const bicOutputLines = [
|
||||
"// Lokale Bankleitzahl-zu-BIC Zuordnung (DE).",
|
||||
"// Quelle: Deutsche Bundesbank, BLZ-Datei (vollstaendig).",
|
||||
"export const DE_BANK_CODE_TO_BIC: Record<string, string> = {",
|
||||
...sortedBics.map(([blz, bic]) => ` "${blz}": "${escapeTsString(bic)}",`),
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
|
||||
await fs.writeFile(OUTPUT_NAME_FILE, nameOutputLines.join("\n"), "utf8")
|
||||
await fs.writeFile(OUTPUT_BIC_FILE, bicOutputLines.join("\n"), "utf8")
|
||||
console.log(`Wrote ${sortedNames.length} bank names to ${OUTPUT_NAME_FILE}`)
|
||||
console.log(`Wrote ${sortedBics.length} bank BICs to ${OUTPUT_BIC_FILE}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
270
backend/scripts/import-members-csv.ts
Normal file
270
backend/scripts/import-members-csv.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db, pool } from "../db"
|
||||
import { customers, entitybankaccounts } from "../db/schema"
|
||||
import { decrypt, encrypt } from "../src/utils/crypt"
|
||||
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||
|
||||
type CsvMemberRow = {
|
||||
number: string
|
||||
lastname: string
|
||||
firstname: string
|
||||
street: string
|
||||
zip: string
|
||||
city: string
|
||||
birthdate: string
|
||||
mobile: string
|
||||
email: string
|
||||
bankInstitute: string
|
||||
iban: string
|
||||
bic: string
|
||||
date: string
|
||||
memberStatus: string
|
||||
}
|
||||
|
||||
const TENANT_ID = 38
|
||||
const DEFAULT_CSV_PATH = "/Users/florianfederspiel/Downloads/Mitglieder Übersicht 2026_1.csv"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const dryRun = args.includes("--dry-run")
|
||||
const csvArg = args.find((arg) => !arg.startsWith("--"))
|
||||
const csvPath = csvArg || DEFAULT_CSV_PATH
|
||||
|
||||
function normalizeIban(value: string) {
|
||||
return String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||
}
|
||||
|
||||
function parseGermanDate(value: string): string | null {
|
||||
const v = String(value || "").trim()
|
||||
if (!v) return null
|
||||
|
||||
const m = v.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/)
|
||||
if (!m) return null
|
||||
|
||||
const day = m[1].padStart(2, "0")
|
||||
const month = m[2].padStart(2, "0")
|
||||
const yy = m[3]
|
||||
const year = yy.length === 4 ? yy : (Number(yy) >= 70 ? `19${yy}` : `20${yy}`)
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function parseBoolFromStatus(value: string) {
|
||||
const normalized = String(value || "").trim().toLowerCase()
|
||||
return normalized !== "inaktiv"
|
||||
}
|
||||
|
||||
function parseCsv(content: string): CsvMemberRow[] {
|
||||
const lines = content
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
|
||||
if (!lines.length) return []
|
||||
|
||||
// Header:
|
||||
// Nr;Name;Vorname;Straße, Hausnr.;PLZ;Wohnort;Geburtsdatum;Mobilfunknummer;Private Mail-Adresse;Kreditinstitut;IBAN;BIC;Datum;Mitgliedsstatus
|
||||
const rows: CsvMemberRow[] = []
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(";").map((v) => v.trim())
|
||||
if (cols.length < 14) continue
|
||||
|
||||
const number = cols[0]
|
||||
const lastname = cols[1]
|
||||
const firstname = cols[2]
|
||||
if (!number || !lastname || !firstname) continue
|
||||
|
||||
rows.push({
|
||||
number,
|
||||
lastname,
|
||||
firstname,
|
||||
street: cols[3] || "",
|
||||
zip: cols[4] || "",
|
||||
city: cols[5] || "",
|
||||
birthdate: cols[6] || "",
|
||||
mobile: cols[7] || "",
|
||||
email: cols[8] || "",
|
||||
bankInstitute: cols[9] || "",
|
||||
iban: cols[10] || "",
|
||||
bic: cols[11] || "",
|
||||
date: cols[12] || "",
|
||||
memberStatus: cols[13] || "",
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
async function loadBankAccountByIban(tenantId: number) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: entitybankaccounts.id,
|
||||
ibanEncrypted: entitybankaccounts.ibanEncrypted,
|
||||
})
|
||||
.from(entitybankaccounts)
|
||||
.where(eq(entitybankaccounts.tenant, tenantId))
|
||||
|
||||
const map = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const iban = normalizeIban(decrypt(row.ibanEncrypted as any))
|
||||
if (iban) map.set(iban, Number(row.id))
|
||||
} catch {
|
||||
// skip broken ciphertext rows
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!secrets.ENCRYPTION_KEY && process.env.ENCRYPTION_KEY) {
|
||||
secrets.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY
|
||||
}
|
||||
|
||||
if (!secrets.ENCRYPTION_KEY && process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_SECRET) {
|
||||
await loadSecrets()
|
||||
}
|
||||
|
||||
if (!secrets.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY fehlt. Bitte ENCRYPTION_KEY setzen oder Infisical-Zugang (INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET) bereitstellen.")
|
||||
}
|
||||
|
||||
const absoluteCsvPath = path.resolve(csvPath)
|
||||
if (!fs.existsSync(absoluteCsvPath)) {
|
||||
throw new Error(`CSV nicht gefunden: ${absoluteCsvPath}`)
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(absoluteCsvPath, "utf8")
|
||||
const csvRows = parseCsv(raw)
|
||||
if (!csvRows.length) {
|
||||
throw new Error("Keine importierbaren Zeilen gefunden.")
|
||||
}
|
||||
|
||||
const existingMembers = await db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.tenant, TENANT_ID), eq(customers.type, "Mitglied")))
|
||||
|
||||
const memberByNumber = new Map(existingMembers.map((m) => [String(m.customerNumber), m]))
|
||||
const bankAccountByIban = await loadBankAccountByIban(TENANT_ID)
|
||||
|
||||
let createdMembers = 0
|
||||
let updatedMembers = 0
|
||||
let createdBankAccounts = 0
|
||||
let skippedNoIban = 0
|
||||
|
||||
for (const row of csvRows) {
|
||||
const iban = normalizeIban(row.iban)
|
||||
if (!iban) {
|
||||
skippedNoIban += 1
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
const fullName = `${row.firstname} ${row.lastname}`.trim()
|
||||
const birthdate = parseGermanDate(row.birthdate)
|
||||
const sepaSignedAt = parseGermanDate(row.date)
|
||||
const active = parseBoolFromStatus(row.memberStatus)
|
||||
|
||||
let bankAccountId = bankAccountByIban.get(iban) || null
|
||||
|
||||
if (!bankAccountId) {
|
||||
if (!dryRun) {
|
||||
const [created] = await db
|
||||
.insert(entitybankaccounts)
|
||||
.values({
|
||||
tenant: TENANT_ID,
|
||||
ibanEncrypted: encrypt(iban),
|
||||
bicEncrypted: encrypt(row.bic || "UNBEKANNT"),
|
||||
bankNameEncrypted: encrypt(row.bankInstitute || "Unbekannt"),
|
||||
description: "Import Mitglieder Uebersicht 2026_1",
|
||||
})
|
||||
.returning({ id: entitybankaccounts.id })
|
||||
bankAccountId = created?.id || null
|
||||
} else {
|
||||
bankAccountId = -1
|
||||
}
|
||||
if (bankAccountId) {
|
||||
bankAccountByIban.set(iban, bankAccountId)
|
||||
createdBankAccounts += 1
|
||||
}
|
||||
}
|
||||
|
||||
const existing = memberByNumber.get(String(row.number))
|
||||
const existingInfo = (existing?.infoData && typeof existing.infoData === "object")
|
||||
? { ...(existing.infoData as Record<string, any>) }
|
||||
: {}
|
||||
|
||||
const existingIds = Array.isArray(existingInfo.bankAccountIds) ? existingInfo.bankAccountIds : []
|
||||
const mergedBankAccountIds = bankAccountId && !existingIds.includes(bankAccountId)
|
||||
? [...existingIds, bankAccountId]
|
||||
: existingIds
|
||||
|
||||
const infoData = {
|
||||
...existingInfo,
|
||||
street: row.street || existingInfo.street || "",
|
||||
zip: row.zip || existingInfo.zip || "",
|
||||
city: row.city || existingInfo.city || "",
|
||||
phone: row.mobile || existingInfo.phone || "",
|
||||
email: row.email || existingInfo.email || "",
|
||||
birthdate: birthdate || existingInfo.birthdate || null,
|
||||
hasSEPA: Boolean(sepaSignedAt || existingInfo.sepaSignedAt || existingInfo.hasSEPA),
|
||||
sepaSignedAt: sepaSignedAt || existingInfo.sepaSignedAt || null,
|
||||
bankAccountIds: mergedBankAccountIds,
|
||||
}
|
||||
|
||||
const payload = {
|
||||
tenant: TENANT_ID,
|
||||
customerNumber: String(row.number),
|
||||
type: "Mitglied",
|
||||
isCompany: false,
|
||||
firstname: row.firstname,
|
||||
lastname: row.lastname,
|
||||
name: fullName,
|
||||
active,
|
||||
infoData,
|
||||
archived: false,
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
if (!dryRun) {
|
||||
const [created] = await db.insert(customers).values(payload).returning()
|
||||
if (created) memberByNumber.set(String(row.number), created)
|
||||
}
|
||||
createdMembers += 1
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
await db
|
||||
.update(customers)
|
||||
.set({
|
||||
...payload,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(customers.id, existing.id), eq(customers.tenant, TENANT_ID)))
|
||||
}
|
||||
updatedMembers += 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log("")
|
||||
console.log(`[IMPORT MEMBERS] Tenant: ${TENANT_ID}`)
|
||||
console.log(`[IMPORT MEMBERS] CSV: ${absoluteCsvPath}`)
|
||||
console.log(`[IMPORT MEMBERS] Dry-Run: ${dryRun ? "JA" : "NEIN"}`)
|
||||
console.log(`[IMPORT MEMBERS] Zeilen: ${csvRows.length}`)
|
||||
console.log(`[IMPORT MEMBERS] Mitglieder erstellt: ${createdMembers}`)
|
||||
console.log(`[IMPORT MEMBERS] Mitglieder aktualisiert: ${updatedMembers}`)
|
||||
console.log(`[IMPORT MEMBERS] Bankkonten erstellt: ${createdBankAccounts}`)
|
||||
console.log(`[IMPORT MEMBERS] Ohne IBAN übersprungen: ${skippedNoIban}`)
|
||||
console.log("")
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("[IMPORT MEMBERS] Fehler:", err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
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
|
||||
})
|
||||
265
backend/scripts/import-skr42-accounts.ts
Normal file
265
backend/scripts/import-skr42-accounts.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import zlib from "node:zlib"
|
||||
|
||||
type ParsedAccount = {
|
||||
number: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const DEFAULT_PDF_PATH = "/Users/florianfederspiel/Downloads/12901_DATEV-Kontenrahmen SKR 42 Vereine, Stiftungen, gGmbH (Bilanz).pdf"
|
||||
const ACCOUNT_CHART = "skr42"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const dryRun = args.includes("--dry-run")
|
||||
const parseOnly = args.includes("--parse-only")
|
||||
const pdfArg = args.find((arg) => !arg.startsWith("--"))
|
||||
const pdfPath = path.resolve(pdfArg || DEFAULT_PDF_PATH)
|
||||
|
||||
function decodePdfString(raw: string) {
|
||||
let out = ""
|
||||
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i]
|
||||
|
||||
if (ch !== "\\") {
|
||||
out += ch
|
||||
continue
|
||||
}
|
||||
|
||||
const next = raw[i + 1]
|
||||
if (!next) break
|
||||
|
||||
if (next === "n") {
|
||||
out += "\n"
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (next === "r") {
|
||||
out += "\r"
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (next === "t") {
|
||||
out += "\t"
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (next === "b") {
|
||||
out += "\b"
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (next === "f") {
|
||||
out += "\f"
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (next === "(" || next === ")" || next === "\\") {
|
||||
out += next
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (/[0-7]/.test(next)) {
|
||||
let oct = next
|
||||
let advance = 1
|
||||
|
||||
for (let j = 2; j <= 3; j += 1) {
|
||||
const c = raw[i + j]
|
||||
if (!c || !/[0-7]/.test(c)) break
|
||||
oct += c
|
||||
advance += 1
|
||||
}
|
||||
|
||||
out += String.fromCharCode(parseInt(oct, 8))
|
||||
i += advance
|
||||
continue
|
||||
}
|
||||
|
||||
out += next
|
||||
i += 1
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function extractTextFromTjOperator(segment: string) {
|
||||
const parts = segment.match(/\((?:\\.|[^\\)])*\)/g)
|
||||
if (!parts) return ""
|
||||
|
||||
return parts
|
||||
.map((p) => decodePdfString(p.slice(1, -1)))
|
||||
.join("")
|
||||
}
|
||||
|
||||
function extractPdfTextStreams(pdfBuffer: Buffer) {
|
||||
const pdfLatin = pdfBuffer.toString("latin1")
|
||||
const texts: string[] = []
|
||||
|
||||
let cursor = 0
|
||||
while (true) {
|
||||
const streamPos = pdfLatin.indexOf("stream", cursor)
|
||||
if (streamPos < 0) break
|
||||
|
||||
let dataStart = streamPos + 6
|
||||
if (pdfLatin[dataStart] === "\r" && pdfLatin[dataStart + 1] === "\n") {
|
||||
dataStart += 2
|
||||
} else if (pdfLatin[dataStart] === "\n") {
|
||||
dataStart += 1
|
||||
}
|
||||
|
||||
const streamEnd = pdfLatin.indexOf("endstream", dataStart)
|
||||
if (streamEnd < 0) break
|
||||
|
||||
const sliceEnd = streamEnd > dataStart && pdfBuffer[streamEnd - 1] === 0x0d
|
||||
? streamEnd - 1
|
||||
: streamEnd
|
||||
|
||||
const compressed = pdfBuffer.subarray(dataStart, sliceEnd)
|
||||
|
||||
try {
|
||||
const inflated = zlib.inflateSync(compressed).toString("latin1")
|
||||
texts.push(inflated)
|
||||
} catch {
|
||||
// ignore non-flate streams
|
||||
}
|
||||
|
||||
cursor = streamEnd + 9
|
||||
}
|
||||
|
||||
return texts
|
||||
}
|
||||
|
||||
function normalizeLabel(value: string) {
|
||||
return value
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s+-\s+/g, "-")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function looksLikeAccountLabel(value: string) {
|
||||
const letters = (value.match(/[A-Za-zÄÖÜäöüß]/g) || []).length
|
||||
return letters >= 3
|
||||
}
|
||||
|
||||
function parseAccountsFromPdf(pdfBuffer: Buffer): ParsedAccount[] {
|
||||
const streams = extractPdfTextStreams(pdfBuffer)
|
||||
const found = new Map<string, string>()
|
||||
|
||||
const accountPattern = /^\s*([A-Z])?\s*(\d{3,5})\s+0\s+(.+)$/
|
||||
|
||||
for (const stream of streams) {
|
||||
const operators = stream.match(/\[(?:.|\r|\n)*?\]TJ|\((?:\\.|[^\\)])*\)Tj/g)
|
||||
if (!operators) continue
|
||||
|
||||
for (const op of operators) {
|
||||
const text = normalizeLabel(extractTextFromTjOperator(op))
|
||||
if (!text) continue
|
||||
|
||||
const m = text.match(accountPattern)
|
||||
if (m) {
|
||||
const number = m[2]
|
||||
const label = normalizeLabel(m[3])
|
||||
if (!looksLikeAccountLabel(label)) continue
|
||||
|
||||
const existing = found.get(number)
|
||||
if (!existing || label.length > existing.length) {
|
||||
found.set(number, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...found.entries()]
|
||||
.map(([number, label]) => ({ number, label }))
|
||||
.sort((a, b) => Number(a.number) - Number(b.number))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(pdfPath)) {
|
||||
throw new Error(`PDF nicht gefunden: ${pdfPath}`)
|
||||
}
|
||||
|
||||
const pdfBuffer = fs.readFileSync(pdfPath)
|
||||
const parsed = parseAccountsFromPdf(pdfBuffer)
|
||||
|
||||
if (!parsed.length) {
|
||||
throw new Error("Keine Konten aus PDF extrahiert.")
|
||||
}
|
||||
|
||||
if (parseOnly) {
|
||||
console.log("")
|
||||
console.log(`[SKR42 IMPORT] PDF: ${pdfPath}`)
|
||||
console.log(`[SKR42 IMPORT] Gefundene Konten: ${parsed.length}`)
|
||||
console.log(`[SKR42 IMPORT] Parse-Only: JA`)
|
||||
console.log("")
|
||||
console.log("[SKR42 IMPORT] Beispiel (erste 15):")
|
||||
for (const item of parsed.slice(0, 15)) {
|
||||
console.log(` ${item.number} ${item.label}`)
|
||||
}
|
||||
console.log("")
|
||||
return
|
||||
}
|
||||
|
||||
const { eq } = await import("drizzle-orm")
|
||||
const { db, pool } = await import("../db")
|
||||
const { accounts } = await import("../db/schema")
|
||||
|
||||
const existing = await db
|
||||
.select({ number: accounts.number })
|
||||
.from(accounts)
|
||||
.where(eq(accounts.accountChart, ACCOUNT_CHART))
|
||||
|
||||
const existingSet = new Set(existing.map((r) => String(r.number)))
|
||||
|
||||
const toInsert = parsed
|
||||
.filter((a) => !existingSet.has(a.number))
|
||||
.map((a) => ({
|
||||
number: a.number,
|
||||
label: a.label,
|
||||
accountChart: ACCOUNT_CHART,
|
||||
description: "DATEV SKR42 Import",
|
||||
}))
|
||||
|
||||
if (!dryRun && toInsert.length > 0) {
|
||||
const batchSize = 500
|
||||
for (let i = 0; i < toInsert.length; i += batchSize) {
|
||||
const batch = toInsert.slice(i, i + batchSize)
|
||||
await db.insert(accounts).values(batch)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("")
|
||||
console.log(`[SKR42 IMPORT] PDF: ${pdfPath}`)
|
||||
console.log(`[SKR42 IMPORT] Gefundene Konten: ${parsed.length}`)
|
||||
console.log(`[SKR42 IMPORT] Bereits vorhanden (skr42): ${existing.length}`)
|
||||
console.log(`[SKR42 IMPORT] Neu einzufuegen: ${toInsert.length}`)
|
||||
console.log(`[SKR42 IMPORT] Dry-Run: ${dryRun ? "JA" : "NEIN"}`)
|
||||
console.log("")
|
||||
|
||||
if (parsed.length > 0) {
|
||||
console.log("[SKR42 IMPORT] Beispiel (erste 15):")
|
||||
for (const item of parsed.slice(0, 15)) {
|
||||
console.log(` ${item.number} ${item.label}`)
|
||||
}
|
||||
console.log("")
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("[SKR42 IMPORT] Fehler:", err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
if (!parseOnly) {
|
||||
const { pool } = await import("../db")
|
||||
await pool.end()
|
||||
}
|
||||
})
|
||||
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
|
||||
}
|
||||
BIN
backend/scripts/skr42.pdf
Normal file
BIN
backend/scripts/skr42.pdf
Normal file
Binary file not shown.
@@ -1,6 +1,5 @@
|
||||
import Fastify from "fastify";
|
||||
import swaggerPlugin from "./plugins/swagger"
|
||||
import supabasePlugin from "./plugins/supabase";
|
||||
import dayjsPlugin from "./plugins/dayjs";
|
||||
import healthRoutes from "./routes/health";
|
||||
import meRoutes from "./routes/auth/me";
|
||||
@@ -29,6 +28,10 @@ import staffTimeRoutes from "./routes/staff/time";
|
||||
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
||||
import userRoutes from "./routes/auth/user";
|
||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||
import wikiRoutes from "./routes/wiki";
|
||||
import portalContractRoutes from "./routes/portal/contracts";
|
||||
import mcpRoutes from "./routes/mcp";
|
||||
import communicationRoutes from "./routes/communication";
|
||||
|
||||
//Public Links
|
||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||
@@ -42,15 +45,19 @@ import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
|
||||
import deviceRoutes from "./routes/internal/devices";
|
||||
import tenantRoutesInternal from "./routes/internal/tenant";
|
||||
import staffTimeRoutesInternal from "./routes/internal/time";
|
||||
import authM2mInternalRoutes from "./routes/internal/auth.m2m";
|
||||
|
||||
//Devices
|
||||
import devicesRFIDRoutes from "./routes/devices/rfid";
|
||||
import devicesManagementRoutes from "./routes/devices/management";
|
||||
|
||||
|
||||
import {sendMail} from "./utils/mailer";
|
||||
import {loadSecrets, secrets} from "./utils/secrets";
|
||||
import {initMailer} from "./utils/mailer"
|
||||
import {initS3} from "./utils/s3";
|
||||
import { runBootstrap } from "./modules/bootstrap.service";
|
||||
|
||||
|
||||
//Services
|
||||
import servicesPlugin from "./plugins/services";
|
||||
@@ -70,12 +77,11 @@ async function main() {
|
||||
|
||||
// Plugins Global verfügbar
|
||||
await app.register(swaggerPlugin);
|
||||
await app.register(corsPlugin);
|
||||
await app.register(supabasePlugin);
|
||||
await app.register(tenantPlugin);
|
||||
await app.register(dayjsPlugin);
|
||||
await app.register(dbPlugin);
|
||||
await app.register(servicesPlugin);
|
||||
await runBootstrap(app);
|
||||
|
||||
app.addHook('preHandler', (req, reply, done) => {
|
||||
console.log(req.method)
|
||||
@@ -107,6 +113,7 @@ async function main() {
|
||||
|
||||
await app.register(async (m2mApp) => {
|
||||
await m2mApp.register(authM2m)
|
||||
await m2mApp.register(authM2mInternalRoutes)
|
||||
await m2mApp.register(helpdeskInboundEmailRoutes)
|
||||
await m2mApp.register(deviceRoutes)
|
||||
await m2mApp.register(tenantRoutesInternal)
|
||||
@@ -115,8 +122,10 @@ async function main() {
|
||||
|
||||
await app.register(async (devicesApp) => {
|
||||
await devicesApp.register(devicesRFIDRoutes)
|
||||
await devicesApp.register(devicesManagementRoutes)
|
||||
},{prefix: "/devices"})
|
||||
|
||||
await app.register(corsPlugin);
|
||||
|
||||
//Geschützte Routes
|
||||
|
||||
@@ -141,6 +150,10 @@ async function main() {
|
||||
await subApp.register(userRoutes);
|
||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||
await subApp.register(resourceRoutes);
|
||||
await subApp.register(wikiRoutes);
|
||||
await subApp.register(portalContractRoutes);
|
||||
await subApp.register(mcpRoutes);
|
||||
await subApp.register(communicationRoutes);
|
||||
|
||||
},{prefix: "/api"})
|
||||
|
||||
@@ -164,4 +177,4 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main();
|
||||
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user