Compare commits
187 Commits
af5bd12577
...
uichange
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 6b9de04d83 | |||
| 529ec0c77d | |||
| 246677b750 | |||
| c839714945 | |||
| 8614917a05 | |||
| 2de80ea6ca | |||
| 6f5fed0ffb | |||
| 767152c535 | |||
| 3128893ba2 | |||
| bcf460cfd5 | |||
| da704be925 | |||
| c049730599 | |||
| 0194345ed8 | |||
| 82d8cd36b9 | |||
| 66110da6c4 | |||
| 267648074c | |||
| 32b4c40e11 | |||
| f044195d86 | |||
| 202e20ddd5 | |||
| 905f2e7bf4 | |||
| b39a52fb20 | |||
| 098bd02808 | |||
| b35c991634 | |||
| dc376894be | |||
| 90788f22da | |||
| d901ebe365 | |||
| 7f6ba99328 | |||
| 8afdf06c8e | |||
| e1205a8de5 | |||
| 08da93b6c3 | |||
| b0ace924d4 | |||
| 593118c181 | |||
| 67d2a05ac4 | |||
| b95d539907 | |||
| 76b363fdaf | |||
| 2cb0d9b607 | |||
| 037a10e93e | |||
| c36b9aa872 | |||
| 14a9435a5a | |||
| 8126c2d3f4 | |||
| a892b7a6e4 | |||
| be7b219569 | |||
| 998d725528 | |||
| 17cd3dc3a3 | |||
| 1d9488b64d | |||
| 9fb520d8c3 | |||
| 3bcfacdf84 | |||
| 8ea41802dd |
38
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
38
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Beschreibung**
|
||||
Eine klare und prägnante Beschreibung des Fehlers.
|
||||
|
||||
**Reproduktion**
|
||||
Schritte, um den Fehler zu reproduzieren:
|
||||
|
||||
Entweder:
|
||||
1. Gehe zu '...'
|
||||
2. Klicke auf '...'
|
||||
3. Scrolle runter zu '...'
|
||||
4. Siehe Fehler
|
||||
|
||||
Oder Link zur Seite
|
||||
|
||||
**Erwartetes Verhalten**
|
||||
Eine klare Beschreibung dessen, was du erwartet hast.
|
||||
|
||||
**Screenshots**
|
||||
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
|
||||
|
||||
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
|
||||
|
||||
**Umgebung:**
|
||||
- Betriebssystem: [z.B. Windows, macOS, Linux]
|
||||
- Browser / Version (falls relevant): [z.B. Chrome 120]
|
||||
- Projekt-Version: [z.B. v1.0.2]
|
||||
|
||||
**Zusätzlicher Kontext**
|
||||
Füge hier alle anderen Informationen zum Problem hinzu.
|
||||
20
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
20
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: Schlage eine Idee für dieses Projekt vor.
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
||||
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
|
||||
|
||||
**Lösungsvorschlag**
|
||||
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
|
||||
|
||||
**Alternativen**
|
||||
Hast du über alternative Lösungen oder Workarounds nachgedacht?
|
||||
|
||||
**Zusätzlicher Kontext**
|
||||
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.
|
||||
77
.gitea/workflows/build-push.yaml
Normal file
77
.gitea/workflows/build-push.yaml
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Build and Push Docker Images
|
||||
run-name: Build Backend & Frontend by @${{ github.actor }}
|
||||
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
# Passe dies an deine Registry an.
|
||||
# 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 }}
|
||||
ACTOR: flfeders
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
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 }}
|
||||
# Gitea stellt secrets.GITHUB_TOKEN automatisch bereit (wie GitLab CI_JOB_TOKEN)
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/backend
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Backend
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./backend # Hier wird der Ordner gewechselt (wie 'cd backend')
|
||||
push: true
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
|
||||
build-frontend:
|
||||
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 Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/frontend
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Frontend
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
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>
|
||||
440
README.md
440
README.md
@@ -1 +1,439 @@
|
||||
TEST
|
||||
# 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
|
||||
|
||||
Der Stack besteht aus:
|
||||
|
||||
- `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
|
||||
|
||||
Die Konfiguration erfolgt uber Umgebungsvariablen beziehungsweise eine `.env`-Datei im Deploy-Verzeichnis.
|
||||
|
||||
## 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 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.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
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Vollstandiges Docker Compose mit optionaler S3-MinIO-Option
|
||||
|
||||
Hinweis: Der Stack unten startet MinIO standardmassig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v2.11
|
||||
container_name: fedeo-traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --api.insecure=false
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --accesslog=true
|
||||
- --accesslog.filepath=/logs/access.log
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./traefik/letsencrypt:/letsencrypt
|
||||
- ./traefik/logs:/logs
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- web
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: fedeo-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- internal
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: fedeo-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- ./minio:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- internal
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc:latest
|
||||
container_name: fedeo-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
|
||||
mc mb --ignore-existing local/${MINIO_BUCKET};
|
||||
mc anonymous set private local/${MINIO_BUCKET};
|
||||
exit 0;
|
||||
"
|
||||
restart: "no"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: fedeo-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
createbuckets:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
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:
|
||||
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 build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Danach Status prufen:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f traefik
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
## 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"}`
|
||||
|
||||
## Updates
|
||||
|
||||
Bei neuen Versionen:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
|
||||
|
||||
## 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,6 +1,14 @@
|
||||
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 ./
|
||||
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
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";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: secrets.DATABASE_URL,
|
||||
max: 10, // je nach Last
|
||||
})
|
||||
console.log("[DB INIT] 1. Suche Connection String...");
|
||||
|
||||
export const db = drizzle(pool)
|
||||
// Checken woher die URL kommt
|
||||
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL;
|
||||
if (connectionString) {
|
||||
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
|
||||
} else {
|
||||
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
|
||||
}
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString,
|
||||
max: 10,
|
||||
});
|
||||
|
||||
// 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;
|
||||
123
backend/db/migrations/0005_green_shinobi_shaw.sql
Normal file
123
backend/db/migrations/0005_green_shinobi_shaw.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
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 "staff_time_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" bigint NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"actor_type" text NOT NULL,
|
||||
"actor_user_id" uuid,
|
||||
"event_time" timestamp with time zone NOT NULL,
|
||||
"event_type" text NOT NULL,
|
||||
"source" text NOT NULL,
|
||||
"invalidates_event_id" uuid,
|
||||
"related_event_id" uuid,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "time_events_actor_user_check" CHECK (
|
||||
(actor_type = 'system' AND actor_user_id IS NULL)
|
||||
OR
|
||||
(actor_type = 'user' AND actor_user_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "serialtypes" (
|
||||
"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 "time_events" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "time_events" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ALTER COLUMN "active_phase" SET DEFAULT 'Erstkontakt';--> statement-breakpoint
|
||||
ALTER TABLE "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 "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_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_actor_user_id_auth_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_invalidates_event_id_staff_time_events_id_fk" FOREIGN KEY ("invalidates_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_related_event_id_staff_time_events_id_fk" FOREIGN KEY ("related_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "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 "idx_time_events_tenant_user_time" ON "staff_time_events" USING btree ("tenant_id","user_id","event_time");--> statement-breakpoint
|
||||
CREATE INDEX "idx_time_events_created_at" ON "staff_time_events" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "idx_time_events_invalidates" ON "staff_time_events" USING btree ("invalidates_event_id");--> statement-breakpoint
|
||||
CREATE INDEX "wiki_pages_tenant_idx" ON "wiki_pages" USING btree ("tenant_id");--> statement-breakpoint
|
||||
CREATE INDEX "wiki_pages_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';
|
||||
108
backend/db/migrations/0017_slow_the_hood.sql
Normal file
108
backend/db/migrations/0017_slow_the_hood.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
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,
|
||||
"billingInterval" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "customerinventoryitems" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"tenant" bigint NOT NULL,
|
||||
"customer" bigint NOT NULL,
|
||||
"customerspace" bigint,
|
||||
"customerInventoryId" text NOT NULL,
|
||||
"serialNumber" text,
|
||||
"quantity" bigint DEFAULT 0 NOT NULL,
|
||||
"manufacturer" text,
|
||||
"manufacturerNumber" text,
|
||||
"purchaseDate" date,
|
||||
"purchasePrice" double precision DEFAULT 0,
|
||||
"currentValue" double precision,
|
||||
"product" bigint,
|
||||
"vendor" bigint,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "customerspaces" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"tenant" bigint NOT NULL,
|
||||
"customer" bigint NOT NULL,
|
||||
"spaceNumber" text NOT NULL,
|
||||
"parentSpace" bigint,
|
||||
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
|
||||
"description" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "entitybankaccounts" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant" bigint NOT NULL,
|
||||
"iban_encrypted" jsonb NOT NULL,
|
||||
"bic_encrypted" jsonb NOT NULL,
|
||||
"bank_name_encrypted" jsonb NOT NULL,
|
||||
"description" text,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid,
|
||||
"archived" boolean DEFAULT false NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "memberrelations" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant" bigint NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"billingInterval" text NOT NULL,
|
||||
"billingAmount" double precision DEFAULT 0 NOT NULL,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;--> statement-breakpoint
|
||||
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;--> statement-breakpoint
|
||||
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;
|
||||
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;
|
||||
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
@@ -36,6 +36,132 @@
|
||||
"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": 1773572400000,
|
||||
"tag": "0020_file_extracted_text",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1773835200000,
|
||||
"tag": "0021_admin_user_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1773925200000,
|
||||
"tag": "0022_task_dependencies",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1774080000000,
|
||||
"tag": "0023_tax_evaluation_period",
|
||||
"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"),
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { tenants } from "./tenants"
|
||||
import { customers } from "./customers"
|
||||
import { contacts } from "./contacts"
|
||||
import { contracttypes } from "./contracttypes"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const contracts = pgTable(
|
||||
@@ -48,6 +49,9 @@ export const contracts = pgTable(
|
||||
contact: bigint("contact", { mode: "number" }).references(
|
||||
() => contacts.id
|
||||
),
|
||||
contracttype: bigint("contracttype", { mode: "number" }).references(
|
||||
() => contracttypes.id
|
||||
),
|
||||
|
||||
bankingIban: text("bankingIban"),
|
||||
bankingBIC: text("bankingBIC"),
|
||||
@@ -57,6 +61,7 @@ export const contracts = pgTable(
|
||||
sepaDate: timestamp("sepaDate", { withTimezone: true }),
|
||||
|
||||
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
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
jsonb,
|
||||
boolean,
|
||||
smallint,
|
||||
doublePrecision,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
@@ -96,7 +97,7 @@ export const createddocuments = pgTable("createddocuments", {
|
||||
|
||||
taxType: text("taxType"),
|
||||
|
||||
customSurchargePercentage: smallint("customSurchargePercentage")
|
||||
customSurchargePercentage: doublePrecision("customSurchargePercentage")
|
||||
.notNull()
|
||||
.default(0),
|
||||
|
||||
|
||||
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
|
||||
@@ -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,7 @@ import { events } from "./events"
|
||||
import { inventoryitemgroups } from "./inventoryitemgroups"
|
||||
import { authUsers } from "./auth_users"
|
||||
import {files} from "./files";
|
||||
import { memberrelations } from "./memberrelations";
|
||||
|
||||
export const historyitems = pgTable("historyitems", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -99,6 +102,12 @@ 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),
|
||||
|
||||
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),
|
||||
|
||||
@@ -13,15 +13,19 @@ export * from "./checks"
|
||||
export * from "./citys"
|
||||
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,7 +46,9 @@ 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"
|
||||
@@ -71,4 +77,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
|
||||
|
||||
@@ -71,7 +71,7 @@ export const projects = pgTable("projects", {
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
|
||||
active_phase: text("active_phase"),
|
||||
active_phase: text("active_phase").default("Erstkontakt"),
|
||||
})
|
||||
|
||||
export type Project = typeof projects.$inferSelect
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -74,6 +74,48 @@ 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,
|
||||
costcentres: 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"),
|
||||
@@ -88,10 +130,13 @@ export const tenants = pgTable(
|
||||
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 },
|
||||
}),
|
||||
accountChart: text("accountChart").notNull().default("skr03"),
|
||||
|
||||
standardEmailForInvoices: text("standardEmailForInvoices"),
|
||||
|
||||
@@ -116,6 +161,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
|
||||
@@ -1,7 +0,0 @@
|
||||
services:
|
||||
backend:
|
||||
image: reg.federspiel.software/fedeo/backend:main
|
||||
restart: always
|
||||
|
||||
environment:
|
||||
|
||||
@@ -6,6 +6,6 @@ export default defineConfig({
|
||||
schema: "./db/schema",
|
||||
out: "./db/migrations",
|
||||
dbCredentials: {
|
||||
url: secrets.DATABASE_URL || process.env.DATABASE_URL,
|
||||
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
|
||||
},
|
||||
})
|
||||
@@ -5,9 +5,14 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.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",
|
||||
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -27,7 +32,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 +52,7 @@
|
||||
"pg": "^8.16.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"webdav-server": "^2.6.2",
|
||||
"xmlbuilder": "^15.1.1",
|
||||
"zpl-image": "^0.2.0",
|
||||
"zpl-renderer-js": "^2.0.2"
|
||||
|
||||
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()
|
||||
})
|
||||
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()
|
||||
}
|
||||
})
|
||||
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,7 @@ 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";
|
||||
|
||||
//Public Links
|
||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||
@@ -42,9 +42,11 @@ 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";
|
||||
@@ -52,6 +54,7 @@ import {loadSecrets, secrets} from "./utils/secrets";
|
||||
import {initMailer} from "./utils/mailer"
|
||||
import {initS3} from "./utils/s3";
|
||||
|
||||
|
||||
//Services
|
||||
import servicesPlugin from "./plugins/services";
|
||||
|
||||
@@ -70,8 +73,6 @@ 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);
|
||||
@@ -107,6 +108,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 +117,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,11 +145,13 @@ async function main() {
|
||||
await subApp.register(userRoutes);
|
||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||
await subApp.register(resourceRoutes);
|
||||
await subApp.register(wikiRoutes);
|
||||
|
||||
},{prefix: "/api"})
|
||||
|
||||
app.ready(async () => {
|
||||
try {
|
||||
console.log("Testing DB Connection:")
|
||||
const result = await app.db.execute("SELECT NOW()");
|
||||
console.log("✓ DB connection OK: " + JSON.stringify(result.rows[0]));
|
||||
} catch (err) {
|
||||
@@ -163,4 +169,4 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main();
|
||||
|
||||
@@ -19,241 +19,243 @@ import {
|
||||
and,
|
||||
} from "drizzle-orm"
|
||||
|
||||
let badMessageDetected = false
|
||||
let badMessageMessageSent = false
|
||||
|
||||
let client: ImapFlow | null = null
|
||||
export function syncDokuboxService (server: FastifyInstance) {
|
||||
let badMessageDetected = false
|
||||
let badMessageMessageSent = false
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// IMAP CLIENT INITIALIZEN
|
||||
// -------------------------------------------------------------
|
||||
export async function initDokuboxClient() {
|
||||
client = new ImapFlow({
|
||||
host: secrets.DOKUBOX_IMAP_HOST,
|
||||
port: secrets.DOKUBOX_IMAP_PORT,
|
||||
secure: secrets.DOKUBOX_IMAP_SECURE,
|
||||
auth: {
|
||||
user: secrets.DOKUBOX_IMAP_USER,
|
||||
pass: secrets.DOKUBOX_IMAP_PASSWORD
|
||||
},
|
||||
logger: false
|
||||
})
|
||||
let client: ImapFlow | null = null
|
||||
|
||||
console.log("Dokubox E-Mail Client Initialized")
|
||||
|
||||
await client.connect()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// MAIN SYNC FUNCTION (DRIZZLE VERSION)
|
||||
// -------------------------------------------------------------
|
||||
export const syncDokubox = (server: FastifyInstance) =>
|
||||
async () => {
|
||||
|
||||
console.log("Perform Dokubox Sync")
|
||||
|
||||
await initDokuboxClient()
|
||||
|
||||
if (!client?.usable) {
|
||||
throw new Error("E-Mail Client not usable")
|
||||
async function initDokuboxClient() {
|
||||
if (client?.usable) {
|
||||
return client
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// TENANTS LADEN (DRIZZLE)
|
||||
// -------------------------------
|
||||
const tenantList = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
emailAddresses: tenants.dokuboxEmailAddresses,
|
||||
key: tenants.dokuboxkey
|
||||
})
|
||||
.from(tenants)
|
||||
client = new ImapFlow({
|
||||
host: secrets.DOKUBOX_IMAP_HOST,
|
||||
port: secrets.DOKUBOX_IMAP_PORT,
|
||||
secure: secrets.DOKUBOX_IMAP_SECURE,
|
||||
auth: {
|
||||
user: secrets.DOKUBOX_IMAP_USER,
|
||||
pass: secrets.DOKUBOX_IMAP_PASSWORD
|
||||
},
|
||||
logger: false
|
||||
})
|
||||
|
||||
const lock = await client.getMailboxLock("INBOX")
|
||||
console.log("Dokubox E-Mail Client Initialized")
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
return client
|
||||
}
|
||||
|
||||
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
|
||||
const syncDokubox = async () => {
|
||||
|
||||
const parsed = await simpleParser(msg.source)
|
||||
console.log("Perform Dokubox Sync")
|
||||
|
||||
const message = {
|
||||
id: msg.uid,
|
||||
subject: parsed.subject,
|
||||
to: parsed.to?.value || [],
|
||||
cc: parsed.cc?.value || [],
|
||||
attachments: parsed.attachments || []
|
||||
}
|
||||
await initDokuboxClient()
|
||||
|
||||
// -------------------------------------------------
|
||||
// MAPPING / FIND TENANT
|
||||
// -------------------------------------------------
|
||||
const config = await getMessageConfigDrizzle(server, message, tenantList)
|
||||
|
||||
if (!config) {
|
||||
badMessageDetected = true
|
||||
|
||||
if (!badMessageMessageSent) {
|
||||
badMessageMessageSent = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.attachments.length > 0) {
|
||||
for (const attachment of message.attachments) {
|
||||
await saveFile(
|
||||
server,
|
||||
config.tenant,
|
||||
message.id,
|
||||
attachment,
|
||||
config.folder,
|
||||
config.filetype
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!client?.usable) {
|
||||
throw new Error("E-Mail Client not usable")
|
||||
}
|
||||
|
||||
if (!badMessageDetected) {
|
||||
badMessageDetected = false
|
||||
badMessageMessageSent = false
|
||||
// -------------------------------
|
||||
// TENANTS LADEN (DRIZZLE)
|
||||
// -------------------------------
|
||||
const tenantList = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
emailAddresses: tenants.dokuboxEmailAddresses,
|
||||
key: tenants.dokuboxkey
|
||||
})
|
||||
.from(tenants)
|
||||
|
||||
const lock = await client.getMailboxLock("INBOX")
|
||||
|
||||
try {
|
||||
|
||||
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
|
||||
|
||||
const parsed = await simpleParser(msg.source)
|
||||
|
||||
const message = {
|
||||
id: msg.uid,
|
||||
subject: parsed.subject,
|
||||
to: parsed.to?.value || [],
|
||||
cc: parsed.cc?.value || [],
|
||||
attachments: parsed.attachments || []
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// MAPPING / FIND TENANT
|
||||
// -------------------------------------------------
|
||||
const config = await getMessageConfigDrizzle(server, message, tenantList)
|
||||
|
||||
if (!config) {
|
||||
badMessageDetected = true
|
||||
|
||||
if (!badMessageMessageSent) {
|
||||
badMessageMessageSent = true
|
||||
}
|
||||
server.log.warn({ messageId: message.id, subject: message.subject }, "Dokubox message could not be mapped to a tenant")
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.attachments.length > 0) {
|
||||
for (const attachment of message.attachments) {
|
||||
await saveFile(
|
||||
server,
|
||||
config.tenant,
|
||||
message.id,
|
||||
attachment,
|
||||
config.folder,
|
||||
config.filetype
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!badMessageDetected) {
|
||||
badMessageDetected = false
|
||||
badMessageMessageSent = false
|
||||
}
|
||||
|
||||
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
|
||||
await client.messageDelete({ seen: true })
|
||||
|
||||
} finally {
|
||||
lock.release()
|
||||
client.close()
|
||||
}
|
||||
|
||||
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
|
||||
await client.messageDelete({ seen: true })
|
||||
|
||||
} finally {
|
||||
lock.release()
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
const getMessageConfigDrizzle = async (
|
||||
server: FastifyInstance,
|
||||
message,
|
||||
tenantsList: any[]
|
||||
) => {
|
||||
|
||||
let possibleKeys: string[] = []
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT ERKENNEN + FOLDER/FILETYPES (DRIZZLE VERSION)
|
||||
// -------------------------------------------------------------
|
||||
const getMessageConfigDrizzle = async (
|
||||
server: FastifyInstance,
|
||||
message,
|
||||
tenantsList: any[]
|
||||
) => {
|
||||
if (message.to) {
|
||||
message.to.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
let possibleKeys: string[] = []
|
||||
if (message.cc) {
|
||||
message.cc.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
if (message.to) {
|
||||
message.to.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
// -------------------------------------------
|
||||
// TENANT IDENTIFY
|
||||
// -------------------------------------------
|
||||
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
|
||||
|
||||
if (message.cc) {
|
||||
message.cc.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
if (!tenant && message.to?.length) {
|
||||
const address = message.to[0].address.toLowerCase()
|
||||
|
||||
// -------------------------------------------
|
||||
// TENANT IDENTIFY
|
||||
// -------------------------------------------
|
||||
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
|
||||
tenant = tenantsList.find((t) =>
|
||||
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
|
||||
)
|
||||
}
|
||||
|
||||
if (!tenant && message.to?.length) {
|
||||
const address = message.to[0].address.toLowerCase()
|
||||
if (!tenant) return null
|
||||
|
||||
tenant = tenantsList.find((t) =>
|
||||
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
|
||||
)
|
||||
}
|
||||
// -------------------------------------------
|
||||
// FOLDER + FILETYPE VIA SUBJECT
|
||||
// -------------------------------------------
|
||||
let folderId = null
|
||||
let filetypeId = null
|
||||
|
||||
if (!tenant) return null
|
||||
// -------------------------------------------
|
||||
// Rechnung / Invoice
|
||||
// -------------------------------------------
|
||||
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
|
||||
|
||||
// -------------------------------------------
|
||||
// FOLDER + FILETYPE VIA SUBJECT
|
||||
// -------------------------------------------
|
||||
let folderId = null
|
||||
let filetypeId = null
|
||||
|
||||
// -------------------------------------------
|
||||
// Rechnung / Invoice
|
||||
// -------------------------------------------
|
||||
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
|
||||
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.tenant, tenant.id),
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.function, "incomingInvoices"),
|
||||
//@ts-ignore
|
||||
eq(folders.year, dayjs().format("YYYY"))
|
||||
eq(folders.tenant, tenant.id),
|
||||
and(
|
||||
eq(folders.function, "incomingInvoices"),
|
||||
//@ts-ignore
|
||||
eq(folders.year, dayjs().format("YYYY"))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1)
|
||||
|
||||
folderId = folder[0]?.id ?? null
|
||||
folderId = folder[0]?.id ?? null
|
||||
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "invoices")
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "invoices")
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1)
|
||||
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Mahnung
|
||||
// -------------------------------------------
|
||||
// Mahnung
|
||||
// -------------------------------------------
|
||||
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
|
||||
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
|
||||
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "reminders")
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "reminders")
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1)
|
||||
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Sonstige Dokumente → Deposit Folder
|
||||
// -------------------------------------------
|
||||
// Sonstige Dokumente → Deposit Folder
|
||||
// -------------------------------------------
|
||||
else {
|
||||
else {
|
||||
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.tenant, tenant.id),
|
||||
eq(folders.function, "deposit")
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.tenant, tenant.id),
|
||||
eq(folders.function, "deposit")
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1)
|
||||
|
||||
folderId = folder[0]?.id ?? null
|
||||
folderId = folder[0]?.id ?? null
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
tenant: tenant.id,
|
||||
folder: folderId,
|
||||
filetype: filetypeId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
tenant: tenant.id,
|
||||
folder: folderId,
|
||||
filetype: filetypeId
|
||||
run: async () => {
|
||||
await syncDokubox()
|
||||
console.log("Service: Dokubox sync finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ import {
|
||||
|
||||
import { eq, and, isNull, not } from "drizzle-orm"
|
||||
|
||||
const formatInvoiceItemDescription = (item: any) => {
|
||||
const parts = [
|
||||
typeof item.description === "string" ? item.description.trim() : "",
|
||||
item.quantity !== null && item.quantity !== undefined
|
||||
? [item.quantity, item.unit].filter(Boolean).join(" ")
|
||||
: (typeof item.unit === "string" ? item.unit.trim() : ""),
|
||||
typeof item.total === "number" ? `${item.total.toFixed(2)} EUR` : "",
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.join(" - ")
|
||||
}
|
||||
|
||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
const processInvoices = async (tenantId:number) => {
|
||||
console.log("▶ Starting Incoming Invoice Preparation")
|
||||
@@ -94,9 +106,9 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
}
|
||||
|
||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||
if (data.invoice_date) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||
if (data.invoice_duedate) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||
|
||||
// Payment terms mapping
|
||||
const mapPayment: any = {
|
||||
@@ -109,16 +121,26 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
|
||||
// 3.2 Positionszeilen konvertieren
|
||||
if (data.invoice_items?.length > 0) {
|
||||
itemInfo.accounts = data.invoice_items.map(item => ({
|
||||
account: item.account_id,
|
||||
description: item.description,
|
||||
amountNet: item.total_without_tax,
|
||||
amountTax: Number((item.total - item.total_without_tax).toFixed(2)),
|
||||
taxType: String(item.tax_rate),
|
||||
amountGross: item.total,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
itemInfo.accounts = data.invoice_items
|
||||
.filter(item => item.description || item.total !== null || item.total_without_tax !== null)
|
||||
.map(item => {
|
||||
const total = typeof item.total === "number" ? item.total : null
|
||||
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||
const amountTax = total !== null && totalWithoutTax !== null
|
||||
? Number((total - totalWithoutTax).toFixed(2))
|
||||
: null
|
||||
|
||||
return {
|
||||
account: item.account_id,
|
||||
description: item.description,
|
||||
amountNet: totalWithoutTax,
|
||||
amountTax,
|
||||
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
||||
amountGross: total,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3.3 Beschreibung generieren
|
||||
@@ -127,7 +149,8 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||
if (data.invoice_items) {
|
||||
for (const item of data.invoice_items) {
|
||||
description += `${item.description} - ${item.quantity} ${item.unit} - ${item.total}\n`
|
||||
const line = formatInvoiceItemDescription(item)
|
||||
if (line) description += `${line}\n`
|
||||
}
|
||||
}
|
||||
itemInfo.description = description.trim()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// modules/helpdesk/helpdesk.contact.service.ts
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { helpdesk_contacts } from "../../../db/schema";
|
||||
|
||||
export async function getOrCreateContact(
|
||||
server: FastifyInstance,
|
||||
@@ -9,30 +11,35 @@ export async function getOrCreateContact(
|
||||
if (!email && !phone) throw new Error('Contact must have at least an email or phone')
|
||||
|
||||
// Bestehenden Kontakt prüfen
|
||||
const { data: existing, error: findError } = await server.supabase
|
||||
.from('helpdesk_contacts')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenant_id)
|
||||
.or(`email.eq.${email || ''},phone.eq.${phone || ''}`)
|
||||
.maybeSingle()
|
||||
const matchConditions = []
|
||||
if (email) matchConditions.push(eq(helpdesk_contacts.email, email))
|
||||
if (phone) matchConditions.push(eq(helpdesk_contacts.phone, phone))
|
||||
|
||||
if (findError) throw findError
|
||||
if (existing) return existing
|
||||
const existing = await server.db
|
||||
.select()
|
||||
.from(helpdesk_contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(helpdesk_contacts.tenantId, tenant_id),
|
||||
or(...matchConditions)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing[0]) return existing[0]
|
||||
|
||||
// Anlegen
|
||||
const { data: created, error: insertError } = await server.supabase
|
||||
.from('helpdesk_contacts')
|
||||
.insert({
|
||||
tenant_id,
|
||||
const created = await server.db
|
||||
.insert(helpdesk_contacts)
|
||||
.values({
|
||||
tenantId: tenant_id,
|
||||
email,
|
||||
phone,
|
||||
display_name,
|
||||
customer_id,
|
||||
contact_id
|
||||
displayName: display_name,
|
||||
customerId: customer_id,
|
||||
contactId: contact_id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
.returning()
|
||||
|
||||
if (insertError) throw insertError
|
||||
return created
|
||||
return created[0]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { getOrCreateContact } from './helpdesk.contact.service.js'
|
||||
import {useNextNumberRangeNumber} from "../../utils/functions";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { customers, helpdesk_contacts, helpdesk_conversations } from "../../../db/schema";
|
||||
|
||||
export async function createConversation(
|
||||
server: FastifyInstance,
|
||||
@@ -25,24 +27,34 @@ export async function createConversation(
|
||||
|
||||
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.insert({
|
||||
tenant_id,
|
||||
contact_id: contactRecord.id,
|
||||
channel_instance_id,
|
||||
const inserted = await server.db
|
||||
.insert(helpdesk_conversations)
|
||||
.values({
|
||||
tenantId: tenant_id,
|
||||
contactId: contactRecord.id,
|
||||
channelInstanceId: channel_instance_id,
|
||||
subject: subject || null,
|
||||
status: 'open',
|
||||
created_at: new Date().toISOString(),
|
||||
customer_id,
|
||||
contact_person_id,
|
||||
ticket_number: usedNumber
|
||||
createdAt: new Date(),
|
||||
customerId: customer_id,
|
||||
contactPersonId: contact_person_id,
|
||||
ticketNumber: usedNumber
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
.returning()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
const data = inserted[0]
|
||||
|
||||
return {
|
||||
...data,
|
||||
channel_instance_id: data.channelInstanceId,
|
||||
contact_id: data.contactId,
|
||||
contact_person_id: data.contactPersonId,
|
||||
created_at: data.createdAt,
|
||||
customer_id: data.customerId,
|
||||
last_message_at: data.lastMessageAt,
|
||||
tenant_id: data.tenantId,
|
||||
ticket_number: data.ticketNumber,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConversations(
|
||||
@@ -52,22 +64,34 @@ export async function getConversations(
|
||||
) {
|
||||
const { status, limit = 50 } = opts || {}
|
||||
|
||||
let query = server.supabase.from('helpdesk_conversations').select('*, customer_id(*)').eq('tenant_id', tenant_id)
|
||||
const filters = [eq(helpdesk_conversations.tenantId, tenant_id)]
|
||||
if (status) filters.push(eq(helpdesk_conversations.status, status))
|
||||
|
||||
if (status) query = query.eq('status', status)
|
||||
query = query.order('last_message_at', { ascending: false }).limit(limit)
|
||||
const data = await server.db
|
||||
.select({
|
||||
conversation: helpdesk_conversations,
|
||||
contact: helpdesk_contacts,
|
||||
customer: customers,
|
||||
})
|
||||
.from(helpdesk_conversations)
|
||||
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
|
||||
.leftJoin(customers, eq(customers.id, helpdesk_conversations.customerId))
|
||||
.where(and(...filters))
|
||||
.orderBy(desc(helpdesk_conversations.lastMessageAt))
|
||||
.limit(limit)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
|
||||
const mappedData = data.map(entry => {
|
||||
return {
|
||||
...entry,
|
||||
customer: entry.customer_id
|
||||
}
|
||||
})
|
||||
|
||||
return mappedData
|
||||
return data.map((entry) => ({
|
||||
...entry.conversation,
|
||||
helpdesk_contacts: entry.contact,
|
||||
channel_instance_id: entry.conversation.channelInstanceId,
|
||||
contact_id: entry.conversation.contactId,
|
||||
contact_person_id: entry.conversation.contactPersonId,
|
||||
created_at: entry.conversation.createdAt,
|
||||
customer_id: entry.customer,
|
||||
last_message_at: entry.conversation.lastMessageAt,
|
||||
tenant_id: entry.conversation.tenantId,
|
||||
ticket_number: entry.conversation.ticketNumber,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function updateConversationStatus(
|
||||
@@ -78,13 +102,22 @@ export async function updateConversationStatus(
|
||||
const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed']
|
||||
if (!valid.includes(status)) throw new Error('Invalid status')
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.update({ status })
|
||||
.eq('id', conversation_id)
|
||||
.select()
|
||||
.single()
|
||||
const updated = await server.db
|
||||
.update(helpdesk_conversations)
|
||||
.set({ status })
|
||||
.where(eq(helpdesk_conversations.id, conversation_id))
|
||||
.returning()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
const data = updated[0]
|
||||
return {
|
||||
...data,
|
||||
channel_instance_id: data.channelInstanceId,
|
||||
contact_id: data.contactId,
|
||||
contact_person_id: data.contactPersonId,
|
||||
created_at: data.createdAt,
|
||||
customer_id: data.customerId,
|
||||
last_message_at: data.lastMessageAt,
|
||||
tenant_id: data.tenantId,
|
||||
ticket_number: data.ticketNumber,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// modules/helpdesk/helpdesk.message.service.ts
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { helpdesk_conversations, helpdesk_messages } from "../../../db/schema";
|
||||
|
||||
export async function addMessage(
|
||||
server: FastifyInstance,
|
||||
@@ -23,38 +25,53 @@ export async function addMessage(
|
||||
) {
|
||||
if (!payload?.text) throw new Error('Message payload requires text content')
|
||||
|
||||
const { data: message, error } = await server.supabase
|
||||
.from('helpdesk_messages')
|
||||
.insert({
|
||||
tenant_id,
|
||||
conversation_id,
|
||||
author_user_id,
|
||||
const inserted = await server.db
|
||||
.insert(helpdesk_messages)
|
||||
.values({
|
||||
tenantId: tenant_id,
|
||||
conversationId: conversation_id,
|
||||
authorUserId: author_user_id,
|
||||
direction,
|
||||
payload,
|
||||
raw_meta,
|
||||
created_at: new Date().toISOString(),
|
||||
rawMeta: raw_meta,
|
||||
externalMessageId: external_message_id,
|
||||
receivedAt: new Date(),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
.returning()
|
||||
|
||||
if (error) throw error
|
||||
const message = inserted[0]
|
||||
|
||||
// Letzte Nachricht aktualisieren
|
||||
await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.update({ last_message_at: new Date().toISOString() })
|
||||
.eq('id', conversation_id)
|
||||
await server.db
|
||||
.update(helpdesk_conversations)
|
||||
.set({ lastMessageAt: new Date() })
|
||||
.where(eq(helpdesk_conversations.id, conversation_id))
|
||||
|
||||
return message
|
||||
return {
|
||||
...message,
|
||||
author_user_id: message.authorUserId,
|
||||
conversation_id: message.conversationId,
|
||||
created_at: message.createdAt,
|
||||
external_message_id: message.externalMessageId,
|
||||
raw_meta: message.rawMeta,
|
||||
tenant_id: message.tenantId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMessages(server: FastifyInstance, conversation_id: string) {
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_messages')
|
||||
.select('*')
|
||||
.eq('conversation_id', conversation_id)
|
||||
.order('created_at', { ascending: true })
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(helpdesk_messages)
|
||||
.where(eq(helpdesk_messages.conversationId, conversation_id))
|
||||
.orderBy(asc(helpdesk_messages.createdAt))
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
return data.map((message) => ({
|
||||
...message,
|
||||
author_user_id: message.authorUserId,
|
||||
conversation_id: message.conversationId,
|
||||
created_at: message.createdAt,
|
||||
external_message_id: message.externalMessageId,
|
||||
raw_meta: message.rawMeta,
|
||||
tenant_id: message.tenantId,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// services/notification.service.ts
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {secrets} from "../utils/secrets";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { notificationsEventTypes, notificationsItems } from "../../db/schema";
|
||||
|
||||
export type NotificationStatus = 'queued' | 'sent' | 'failed';
|
||||
|
||||
@@ -34,16 +36,16 @@ export class NotificationService {
|
||||
*/
|
||||
async trigger(input: TriggerInput) {
|
||||
const { tenantId, userId, eventType, title, message, payload } = input;
|
||||
const supabase = this.server.supabase;
|
||||
|
||||
// 1) Event-Typ prüfen (aktiv?)
|
||||
const { data: eventTypeRow, error: etErr } = await supabase
|
||||
.from('notifications_event_types')
|
||||
.select('event_key,is_active')
|
||||
.eq('event_key', eventType)
|
||||
.maybeSingle();
|
||||
const eventTypeRows = await this.server.db
|
||||
.select()
|
||||
.from(notificationsEventTypes)
|
||||
.where(eq(notificationsEventTypes.eventKey, eventType))
|
||||
.limit(1)
|
||||
const eventTypeRow = eventTypeRows[0]
|
||||
|
||||
if (etErr || !eventTypeRow || eventTypeRow.is_active !== true) {
|
||||
if (!eventTypeRow || eventTypeRow.isActive !== true) {
|
||||
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
|
||||
}
|
||||
|
||||
@@ -54,40 +56,40 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
// 3) Notification anlegen (status: queued)
|
||||
const { data: inserted, error: insErr } = await supabase
|
||||
.from('notifications_items')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
event_type: eventType,
|
||||
const insertedRows = await this.server.db
|
||||
.insert(notificationsItems)
|
||||
.values({
|
||||
tenantId,
|
||||
userId,
|
||||
eventType,
|
||||
title,
|
||||
message,
|
||||
payload: payload ?? null,
|
||||
channel: 'email',
|
||||
status: 'queued'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
.returning({ id: notificationsItems.id })
|
||||
const inserted = insertedRows[0]
|
||||
|
||||
if (insErr || !inserted) {
|
||||
throw new Error(`Fehler beim Einfügen der Notification: ${insErr?.message}`);
|
||||
if (!inserted) {
|
||||
throw new Error("Fehler beim Einfügen der Notification");
|
||||
}
|
||||
|
||||
// 4) E-Mail versenden
|
||||
try {
|
||||
await this.sendEmail(user.email, title, message);
|
||||
|
||||
await supabase
|
||||
.from('notifications_items')
|
||||
.update({ status: 'sent', sent_at: new Date().toISOString() })
|
||||
.eq('id', inserted.id);
|
||||
await this.server.db
|
||||
.update(notificationsItems)
|
||||
.set({ status: 'sent', sentAt: new Date() })
|
||||
.where(eq(notificationsItems.id, inserted.id));
|
||||
|
||||
return { success: true, id: inserted.id };
|
||||
} catch (err: any) {
|
||||
await supabase
|
||||
.from('notifications_items')
|
||||
.update({ status: 'failed', error: String(err?.message || err) })
|
||||
.eq('id', inserted.id);
|
||||
await this.server.db
|
||||
.update(notificationsItems)
|
||||
.set({ status: 'failed', error: String(err?.message || err) })
|
||||
.where(eq(notificationsItems.id, inserted.id));
|
||||
|
||||
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
|
||||
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
||||
import Handlebars from "handlebars";
|
||||
import axios from "axios";
|
||||
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
|
||||
|
||||
@@ -10,6 +9,7 @@ import { saveFile } from "../utils/files";
|
||||
import {FastifyInstance} from "fastify";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
||||
import { documentTemplateHandlebars } from "../utils/handlebars";
|
||||
|
||||
dayjs.extend(quarterOfYear);
|
||||
|
||||
@@ -609,8 +609,8 @@ export function getDocumentDataBackend(
|
||||
};
|
||||
};
|
||||
|
||||
const templateStartText = Handlebars.compile(itemInfo.startText || "");
|
||||
const templateEndText = Handlebars.compile(itemInfo.endText || "");
|
||||
const templateStartText = documentTemplateHandlebars.compile(itemInfo.startText || "");
|
||||
const templateEndText = documentTemplateHandlebars.compile(itemInfo.endText || "");
|
||||
|
||||
// --- 6. Title Sums Formatting ---
|
||||
let returnTitleSums: Record<string, string> = {};
|
||||
|
||||
249
backend/src/modules/service-price-recalculation.service.ts
Normal file
249
backend/src/modules/service-price-recalculation.service.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "../../db/schema";
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
type CompositionRow = {
|
||||
product?: number | string | null;
|
||||
service?: number | string | null;
|
||||
hourrate?: string | null;
|
||||
quantity?: number | string | null;
|
||||
price?: number | string | null;
|
||||
purchasePrice?: number | string | null;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
function toNumber(value: any): number {
|
||||
const num = Number(value ?? 0);
|
||||
return Number.isFinite(num) ? num : 0;
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
function getJsonNumber(source: unknown, key: string): number {
|
||||
if (!source || typeof source !== "object") return 0;
|
||||
return toNumber((source as Record<string, unknown>)[key]);
|
||||
}
|
||||
|
||||
function normalizeId(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function normalizeUuid(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function sanitizeCompositionRows(value: unknown): CompositionRow[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry): entry is CompositionRow => !!entry && typeof entry === "object");
|
||||
}
|
||||
|
||||
export async function recalculateServicePricesForTenant(server: FastifyInstance, tenantId: number, updatedBy?: string | null) {
|
||||
const [services, products, hourrates] = await Promise.all([
|
||||
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)),
|
||||
server.db.select().from(schema.products).where(eq(schema.products.tenant, tenantId)),
|
||||
server.db.select().from(schema.hourrates).where(eq(schema.hourrates.tenant, tenantId)),
|
||||
]);
|
||||
|
||||
const serviceMap = new Map(services.map((item) => [item.id, item]));
|
||||
const productMap = new Map(products.map((item) => [item.id, item]));
|
||||
const hourrateMap = new Map(hourrates.map((item) => [item.id, item]));
|
||||
|
||||
const memo = new Map<number, {
|
||||
sellingTotal: number;
|
||||
purchaseTotal: number;
|
||||
materialTotal: number;
|
||||
materialPurchaseTotal: number;
|
||||
workerTotal: number;
|
||||
workerPurchaseTotal: number;
|
||||
materialComposition: CompositionRow[];
|
||||
personalComposition: CompositionRow[];
|
||||
}>();
|
||||
const stack = new Set<number>();
|
||||
|
||||
const calculateService = (serviceId: number) => {
|
||||
if (memo.has(serviceId)) return memo.get(serviceId)!;
|
||||
|
||||
const service = serviceMap.get(serviceId);
|
||||
const emptyResult = {
|
||||
sellingTotal: 0,
|
||||
purchaseTotal: 0,
|
||||
materialTotal: 0,
|
||||
materialPurchaseTotal: 0,
|
||||
workerTotal: 0,
|
||||
workerPurchaseTotal: 0,
|
||||
materialComposition: [],
|
||||
personalComposition: [],
|
||||
};
|
||||
|
||||
if (!service) return emptyResult;
|
||||
if (stack.has(serviceId)) return emptyResult;
|
||||
|
||||
// Gesperrte Leistungen bleiben bei automatischen Preis-Updates unverändert.
|
||||
if (service.priceUpdateLocked) {
|
||||
const lockedResult = {
|
||||
sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
|
||||
purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
|
||||
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
|
||||
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
|
||||
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
|
||||
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
|
||||
materialComposition: sanitizeCompositionRows(service.materialComposition),
|
||||
personalComposition: sanitizeCompositionRows(service.personalComposition),
|
||||
};
|
||||
memo.set(serviceId, lockedResult);
|
||||
return lockedResult;
|
||||
}
|
||||
|
||||
stack.add(serviceId);
|
||||
try {
|
||||
const materialComposition = sanitizeCompositionRows(service.materialComposition);
|
||||
const personalComposition = sanitizeCompositionRows(service.personalComposition);
|
||||
const hasMaterialComposition = materialComposition.length > 0;
|
||||
const hasPersonalComposition = personalComposition.length > 0;
|
||||
|
||||
// Ohne Zusammensetzung keine automatische Überschreibung:
|
||||
// manuell gepflegte Preise sollen erhalten bleiben.
|
||||
if (!hasMaterialComposition && !hasPersonalComposition) {
|
||||
const manualResult = {
|
||||
sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
|
||||
purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
|
||||
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
|
||||
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
|
||||
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
|
||||
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
|
||||
materialComposition,
|
||||
personalComposition,
|
||||
};
|
||||
memo.set(serviceId, manualResult);
|
||||
return manualResult;
|
||||
}
|
||||
|
||||
let materialTotal = 0;
|
||||
let materialPurchaseTotal = 0;
|
||||
|
||||
const normalizedMaterialComposition = materialComposition.map((entry) => {
|
||||
const quantity = toNumber(entry.quantity);
|
||||
const productId = normalizeId(entry.product);
|
||||
const childServiceId = normalizeId(entry.service);
|
||||
|
||||
let sellingPrice = toNumber(entry.price);
|
||||
let purchasePrice = toNumber(entry.purchasePrice);
|
||||
|
||||
if (productId) {
|
||||
const product = productMap.get(productId);
|
||||
sellingPrice = toNumber(product?.selling_price);
|
||||
purchasePrice = toNumber(product?.purchase_price);
|
||||
} else if (childServiceId) {
|
||||
const child = calculateService(childServiceId);
|
||||
sellingPrice = toNumber(child.sellingTotal);
|
||||
purchasePrice = toNumber(child.purchaseTotal);
|
||||
}
|
||||
|
||||
materialTotal += quantity * sellingPrice;
|
||||
materialPurchaseTotal += quantity * purchasePrice;
|
||||
|
||||
return {
|
||||
...entry,
|
||||
price: round2(sellingPrice),
|
||||
purchasePrice: round2(purchasePrice),
|
||||
};
|
||||
});
|
||||
|
||||
let workerTotal = 0;
|
||||
let workerPurchaseTotal = 0;
|
||||
const normalizedPersonalComposition = personalComposition.map((entry) => {
|
||||
const quantity = toNumber(entry.quantity);
|
||||
const hourrateId = normalizeUuid(entry.hourrate);
|
||||
|
||||
let sellingPrice = toNumber(entry.price);
|
||||
let purchasePrice = toNumber(entry.purchasePrice);
|
||||
|
||||
if (hourrateId) {
|
||||
const hourrate = hourrateMap.get(hourrateId);
|
||||
if (hourrate) {
|
||||
sellingPrice = toNumber(hourrate.sellingPrice);
|
||||
purchasePrice = toNumber(hourrate.purchase_price);
|
||||
}
|
||||
}
|
||||
|
||||
workerTotal += quantity * sellingPrice;
|
||||
workerPurchaseTotal += quantity * purchasePrice;
|
||||
|
||||
return {
|
||||
...entry,
|
||||
price: round2(sellingPrice),
|
||||
purchasePrice: round2(purchasePrice),
|
||||
};
|
||||
});
|
||||
|
||||
const result = {
|
||||
sellingTotal: round2(materialTotal + workerTotal),
|
||||
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal),
|
||||
materialTotal: round2(materialTotal),
|
||||
materialPurchaseTotal: round2(materialPurchaseTotal),
|
||||
workerTotal: round2(workerTotal),
|
||||
workerPurchaseTotal: round2(workerPurchaseTotal),
|
||||
materialComposition: normalizedMaterialComposition,
|
||||
personalComposition: normalizedPersonalComposition,
|
||||
};
|
||||
|
||||
memo.set(serviceId, result);
|
||||
return result;
|
||||
} finally {
|
||||
stack.delete(serviceId);
|
||||
}
|
||||
};
|
||||
|
||||
for (const service of services) {
|
||||
calculateService(service.id);
|
||||
}
|
||||
|
||||
const updates = services
|
||||
.filter((service) => !service.priceUpdateLocked)
|
||||
.map(async (service) => {
|
||||
const calc = memo.get(service.id);
|
||||
if (!calc) return;
|
||||
|
||||
const sellingPriceComposed = {
|
||||
worker: calc.workerTotal,
|
||||
material: calc.materialTotal,
|
||||
total: calc.sellingTotal,
|
||||
};
|
||||
|
||||
const purchasePriceComposed = {
|
||||
worker: calc.workerPurchaseTotal,
|
||||
material: calc.materialPurchaseTotal,
|
||||
total: calc.purchaseTotal,
|
||||
};
|
||||
|
||||
const unchanged =
|
||||
JSON.stringify(service.materialComposition ?? []) === JSON.stringify(calc.materialComposition) &&
|
||||
JSON.stringify(service.personalComposition ?? []) === JSON.stringify(calc.personalComposition) &&
|
||||
JSON.stringify(service.sellingPriceComposed ?? {}) === JSON.stringify(sellingPriceComposed) &&
|
||||
JSON.stringify(service.purchasePriceComposed ?? {}) === JSON.stringify(purchasePriceComposed) &&
|
||||
round2(toNumber(service.sellingPrice)) === calc.sellingTotal;
|
||||
|
||||
if (unchanged) return;
|
||||
|
||||
await server.db
|
||||
.update(schema.services)
|
||||
.set({
|
||||
materialComposition: calc.materialComposition,
|
||||
personalComposition: calc.personalComposition,
|
||||
sellingPriceComposed,
|
||||
purchasePriceComposed,
|
||||
sellingPrice: calc.sellingTotal,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: updatedBy ?? null,
|
||||
})
|
||||
.where(and(eq(schema.services.id, service.id), eq(schema.services.tenant, tenantId)));
|
||||
});
|
||||
|
||||
await Promise.all(updates);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { secrets } from "../utils/secrets";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { authUsers, m2mApiKeys } from "../../db/schema";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Fastify Plugin für Machine-to-Machine Authentifizierung.
|
||||
@@ -12,26 +15,99 @@ import { secrets } from "../utils/secrets";
|
||||
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
|
||||
*/
|
||||
export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => {
|
||||
//const allowedPrefix = opts.allowedPrefix || "/internal";
|
||||
const hashApiKey = (apiKey: string) =>
|
||||
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||||
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
try {
|
||||
// Nur prüfen, wenn Route unterhalb des Prefix liegt
|
||||
//if (!req.url.startsWith(allowedPrefix)) return;
|
||||
const apiKeyHeader = req.headers["x-api-key"];
|
||||
const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
|
||||
|
||||
const apiKey = req.headers["x-api-key"];
|
||||
|
||||
if (!apiKey || apiKey !== secrets.M2M_API_KEY) {
|
||||
if (!apiKey) {
|
||||
server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
|
||||
return reply.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
// Zusatzinformationen im Request (z. B. interne Kennung)
|
||||
const keyHash = hashApiKey(apiKey);
|
||||
|
||||
const keyRows = await server.db
|
||||
.select({
|
||||
id: m2mApiKeys.id,
|
||||
tenantId: m2mApiKeys.tenantId,
|
||||
userId: m2mApiKeys.userId,
|
||||
active: m2mApiKeys.active,
|
||||
expiresAt: m2mApiKeys.expiresAt,
|
||||
name: m2mApiKeys.name,
|
||||
userEmail: authUsers.email,
|
||||
})
|
||||
.from(m2mApiKeys)
|
||||
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
||||
.where(and(
|
||||
eq(m2mApiKeys.keyHash, keyHash),
|
||||
eq(m2mApiKeys.active, true)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
let key = keyRows[0]
|
||||
if (!key) {
|
||||
const fallbackValid = apiKey === secrets.M2M_API_KEY
|
||||
if (!fallbackValid) {
|
||||
server.log.warn(`[M2M Auth] Ungültiger API-Key bei ${req.url}`)
|
||||
return reply.status(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
// Backward compatibility mode for one global key.
|
||||
// The caller must provide user/tenant identifiers in headers.
|
||||
const tenantIdHeader = req.headers["x-tenant-id"]
|
||||
const userIdHeader = req.headers["x-user-id"]
|
||||
const tenantId = Number(Array.isArray(tenantIdHeader) ? tenantIdHeader[0] : tenantIdHeader)
|
||||
const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader
|
||||
|
||||
if (!tenantId || !userId) {
|
||||
return reply.status(401).send({ error: "Missing x-tenant-id or x-user-id for legacy M2M key" })
|
||||
}
|
||||
|
||||
const users = await server.db
|
||||
.select({ email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!users[0]) {
|
||||
return reply.status(401).send({ error: "Unknown user for legacy M2M key" })
|
||||
}
|
||||
|
||||
req.user = {
|
||||
user_id: userId,
|
||||
email: users[0].email,
|
||||
tenant_id: tenantId
|
||||
}
|
||||
} else {
|
||||
if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) {
|
||||
return reply.status(401).send({ error: "Expired API key" })
|
||||
}
|
||||
|
||||
req.user = {
|
||||
user_id: key.userId,
|
||||
email: key.userEmail,
|
||||
tenant_id: key.tenantId
|
||||
}
|
||||
|
||||
await server.db
|
||||
.update(m2mApiKeys)
|
||||
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(m2mApiKeys.id, key.id))
|
||||
}
|
||||
|
||||
(req as any).m2m = {
|
||||
verified: true,
|
||||
type: "internal",
|
||||
key: apiKey,
|
||||
};
|
||||
|
||||
req.role = "m2m"
|
||||
req.permissions = []
|
||||
req.hasPermission = () => false
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { secrets } from "../utils/secrets"
|
||||
import {
|
||||
authUserRoles,
|
||||
authRolePermissions,
|
||||
authUsers,
|
||||
} from "../../db/schema"
|
||||
|
||||
import { eq, and } from "drizzle-orm"
|
||||
@@ -43,6 +44,16 @@ export default fp(async (server: FastifyInstance) => {
|
||||
// Payload an Request hängen
|
||||
req.user = payload
|
||||
|
||||
const [currentUser] = await server.db
|
||||
.select({
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, payload.user_id))
|
||||
.limit(1)
|
||||
|
||||
req.user.is_admin = Boolean(currentUser?.is_admin)
|
||||
|
||||
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
||||
if (!req.user.tenant_id) {
|
||||
return
|
||||
@@ -66,6 +77,13 @@ export default fp(async (server: FastifyInstance) => {
|
||||
.limit(1)
|
||||
|
||||
if (roleRows.length === 0) {
|
||||
if (req.user.is_admin) {
|
||||
req.role = ""
|
||||
req.permissions = []
|
||||
req.hasPermission = () => false
|
||||
return
|
||||
}
|
||||
|
||||
return reply
|
||||
.code(403)
|
||||
.send({ error: "No role assigned for this tenant" })
|
||||
@@ -107,6 +125,7 @@ declare module "fastify" {
|
||||
user_id: string
|
||||
email: string
|
||||
tenant_id: number | null
|
||||
is_admin?: boolean
|
||||
}
|
||||
role: string
|
||||
permissions: string[]
|
||||
|
||||
@@ -9,13 +9,15 @@ export default fp(async (server: FastifyInstance) => {
|
||||
"http://localhost:3001", // dein Nuxt-Frontend
|
||||
"http://127.0.0.1:3000", // dein Nuxt-Frontend
|
||||
"http://192.168.1.227:3001", // dein Nuxt-Frontend
|
||||
"http://192.168.1.227:3000", // dein Nuxt-Frontend
|
||||
"http://192.168.1.234:3000", // dein Nuxt-Frontend
|
||||
"http://192.168.1.113:3000", // dein Nuxt-Frontend
|
||||
"https://beta.fedeo.de", // dein Nuxt-Frontend
|
||||
"https://app.fedeo.de", // dein Nuxt-Frontend
|
||||
"capacitor://localhost", // dein Nuxt-Frontend
|
||||
],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS","PATCH",
|
||||
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin","Depth", "Overwrite", "Destination", "Lock-Token", "If"],
|
||||
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
|
||||
credentials: true, // wichtig, falls du Cookies nutzt
|
||||
});
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
import fp from "fastify-plugin"
|
||||
import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres"
|
||||
import { Pool } from "pg"
|
||||
import * as schema from "../../db/schema"
|
||||
// src/plugins/db.ts
|
||||
import fp from "fastify-plugin";
|
||||
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "../../db/schema";
|
||||
import { db, pool } from "../../db"; // <--- Importiert jetzt die globale Instanz
|
||||
|
||||
export default fp(async (server, opts) => {
|
||||
const pool = new Pool({
|
||||
host: "100.102.185.225",
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
user: "postgres",
|
||||
password: "wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu",
|
||||
database: "fedeo",
|
||||
ssl: process.env.DB_DISABLE_SSL === "true" ? false : undefined,
|
||||
})
|
||||
|
||||
// Drizzle instance
|
||||
const db = drizzle(pool, { schema })
|
||||
// Wir nutzen die db, die wir in src/db/index.ts erstellt haben
|
||||
server.decorate("db", db);
|
||||
|
||||
// Dekorieren -> überall server.db
|
||||
server.decorate("db", db)
|
||||
|
||||
// Graceful Shutdown
|
||||
// Graceful Shutdown: Wenn Fastify ausgeht, schließen wir den Pool
|
||||
server.addHook("onClose", async () => {
|
||||
await pool.end()
|
||||
})
|
||||
console.log("[DB] Closing connection pool...");
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
server.log.info("Drizzle database connected")
|
||||
})
|
||||
console.log("[Fastify] Database attached from shared instance");
|
||||
});
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
db:NodePgDatabase<typeof schema>
|
||||
db: NodePgDatabase<typeof schema>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,6 @@ const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
|
||||
|
||||
const query = req.query as Record<string, any>
|
||||
|
||||
console.log(query)
|
||||
|
||||
// Pagination deaktivieren?
|
||||
const disablePagination =
|
||||
query.noPagination === 'true' ||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// /plugins/services.ts
|
||||
import fp from "fastify-plugin";
|
||||
import { bankStatementService } from "../modules/cron/bankstatementsync.service";
|
||||
//import {initDokuboxClient, syncDokubox} from "../modules/cron/dokuboximport.service";
|
||||
import {syncDokuboxService} from "../modules/cron/dokuboximport.service";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices";
|
||||
|
||||
@@ -9,7 +9,7 @@ declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
services: {
|
||||
bankStatements: ReturnType<typeof bankStatementService>;
|
||||
//dokuboxSync: ReturnType<typeof syncDokubox>;
|
||||
dokuboxSync: ReturnType<typeof syncDokuboxService>;
|
||||
prepareIncomingInvoices: ReturnType<typeof prepareIncomingInvoices>;
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ declare module "fastify" {
|
||||
export default fp(async function servicePlugin(server: FastifyInstance) {
|
||||
server.decorate("services", {
|
||||
bankStatements: bankStatementService(server),
|
||||
//dokuboxSync: syncDokubox(server),
|
||||
dokuboxSync: syncDokuboxService(server),
|
||||
prepareIncomingInvoices: prepareIncomingInvoices(server),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
import {secrets} from "../utils/secrets";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
const supabaseUrl = secrets.SUPABASE_URL
|
||||
const supabaseServiceKey = secrets.SUPABASE_SERVICE_ROLE_KEY
|
||||
const supabase: SupabaseClient = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Fastify um supabase erweitern
|
||||
server.decorate("supabase", supabase);
|
||||
});
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
supabase: SupabaseClient;
|
||||
}
|
||||
}
|
||||
@@ -5,26 +5,33 @@ import swaggerUi from "@fastify/swagger-ui";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
await server.register(swagger, {
|
||||
mode: "dynamic", // wichtig: generiert echtes OpenAPI JSON
|
||||
mode: "dynamic",
|
||||
openapi: {
|
||||
info: {
|
||||
title: "Multi-Tenant API",
|
||||
description: "API Dokumentation für dein Backend",
|
||||
title: "FEDEO Backend API",
|
||||
description: "OpenAPI specification for the FEDEO backend",
|
||||
version: "1.0.0",
|
||||
},
|
||||
servers: [{ url: "http://localhost:3000" }],
|
||||
servers: [{ url: "/" }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
await server.register(swaggerUi, {
|
||||
routePrefix: "/docs", // UI erreichbar unter http://localhost:3000/docs
|
||||
swagger: {
|
||||
info: {
|
||||
title: "Multi-Tenant API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
exposeRoute: true,
|
||||
routePrefix: "/docs",
|
||||
});
|
||||
});
|
||||
|
||||
// Stable raw spec path
|
||||
server.get("/openapi.json", async (_req, reply) => {
|
||||
return reply.send(server.swagger());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { tenants } from "../../db/schema";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
@@ -9,11 +11,12 @@ export default fp(async (server: FastifyInstance) => {
|
||||
return;
|
||||
}
|
||||
// Tenant aus DB laden
|
||||
const { data: tenant } = await server.supabase
|
||||
.from("tenants")
|
||||
.select("*")
|
||||
.eq("portalDomain", host)
|
||||
.single();
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(tenants)
|
||||
.where(eq(tenants.portalDomain, host))
|
||||
.limit(1);
|
||||
const tenant = rows[0];
|
||||
|
||||
|
||||
if(!tenant) {
|
||||
@@ -38,4 +41,4 @@ declare module "fastify" {
|
||||
settings?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,761 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
authTenantUsers,
|
||||
authProfiles,
|
||||
authRoles,
|
||||
authUserRoles,
|
||||
authUsers,
|
||||
filetags,
|
||||
folders,
|
||||
tenants,
|
||||
} from "../../db/schema";
|
||||
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||
|
||||
export default async function adminRoutes(server: FastifyInstance) {
|
||||
const deriveNameFromEmail = (email: string) => {
|
||||
const localPart = email.split("@")[0] || "Benutzer";
|
||||
const normalized = localPart.replace(/[._-]+/g, " ").trim();
|
||||
const parts = normalized.split(/\s+/).filter(Boolean);
|
||||
const firstName = parts[0]
|
||||
? parts[0].charAt(0).toUpperCase() + parts[0].slice(1)
|
||||
: "Neuer";
|
||||
const lastName = parts.length > 1
|
||||
? parts.slice(1).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ")
|
||||
: "Benutzer";
|
||||
|
||||
return { first_name: firstName, last_name: lastName };
|
||||
};
|
||||
|
||||
const createTenantSeeds = async (tenantId: number, createdBy: string) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const timestamp = new Date();
|
||||
|
||||
const insertedTags = await server.db
|
||||
.insert(filetags)
|
||||
.values([
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Rechnungen",
|
||||
color: "#16a34a",
|
||||
createdDocumentType: "invoices",
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Angebote",
|
||||
color: "#2563eb",
|
||||
createdDocumentType: "quotes",
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Auftragsbestätigungen",
|
||||
color: "#7c3aed",
|
||||
createdDocumentType: "confirmationOrders",
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Lieferscheine",
|
||||
color: "#ea580c",
|
||||
createdDocumentType: "deliveryNotes",
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Eingangsrechnungen",
|
||||
color: "#dc2626",
|
||||
incomingDocumentType: "invoices",
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Mahnungen",
|
||||
color: "#b91c1c",
|
||||
incomingDocumentType: "reminders",
|
||||
},
|
||||
])
|
||||
.returning({
|
||||
id: filetags.id,
|
||||
name: filetags.name,
|
||||
createdDocumentType: filetags.createdDocumentType,
|
||||
incomingDocumentType: filetags.incomingDocumentType,
|
||||
});
|
||||
|
||||
const invoiceTag = insertedTags.find((tag) => tag.createdDocumentType === "invoices");
|
||||
const quoteTag = insertedTags.find((tag) => tag.createdDocumentType === "quotes");
|
||||
const confirmationTag = insertedTags.find((tag) => tag.createdDocumentType === "confirmationOrders");
|
||||
const deliveryTag = insertedTags.find((tag) => tag.createdDocumentType === "deliveryNotes");
|
||||
const incomingInvoiceTag = insertedTags.find((tag) => tag.incomingDocumentType === "invoices");
|
||||
|
||||
const insertedFolders = await server.db
|
||||
.insert(folders)
|
||||
.values([
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Ausgangsrechnungen",
|
||||
function: "yearSubCategory",
|
||||
icon: "i-heroicons-document-text",
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Angebote",
|
||||
function: "yearSubCategory",
|
||||
icon: "i-heroicons-document-duplicate",
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Auftragsbestätigungen",
|
||||
function: "yearSubCategory",
|
||||
icon: "i-heroicons-clipboard-document-check",
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Lieferscheine",
|
||||
function: "yearSubCategory",
|
||||
icon: "i-heroicons-truck",
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Eingangsrechnungen",
|
||||
function: "yearSubCategory",
|
||||
icon: "i-heroicons-inbox-arrow-down",
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: "Belege Bankeinzahlung",
|
||||
function: "yearSubCategory",
|
||||
icon: "i-heroicons-banknotes",
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
])
|
||||
.returning({
|
||||
id: folders.id,
|
||||
name: folders.name,
|
||||
});
|
||||
|
||||
const folderByName = new Map(insertedFolders.map((folder) => [folder.name, folder.id]));
|
||||
|
||||
await server.db
|
||||
.insert(folders)
|
||||
.values([
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: String(currentYear),
|
||||
parent: folderByName.get("Ausgangsrechnungen"),
|
||||
function: "invoices",
|
||||
year: currentYear,
|
||||
icon: "i-heroicons-document-text",
|
||||
standardFiletype: invoiceTag?.id,
|
||||
standardFiletypeIsOptional: false,
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: String(currentYear),
|
||||
parent: folderByName.get("Angebote"),
|
||||
function: "quotes",
|
||||
year: currentYear,
|
||||
icon: "i-heroicons-document-duplicate",
|
||||
standardFiletype: quoteTag?.id,
|
||||
standardFiletypeIsOptional: false,
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: String(currentYear),
|
||||
parent: folderByName.get("Auftragsbestätigungen"),
|
||||
function: "confirmationOrders",
|
||||
year: currentYear,
|
||||
icon: "i-heroicons-clipboard-document-check",
|
||||
standardFiletype: confirmationTag?.id,
|
||||
standardFiletypeIsOptional: false,
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: String(currentYear),
|
||||
parent: folderByName.get("Lieferscheine"),
|
||||
function: "deliveryNotes",
|
||||
year: currentYear,
|
||||
icon: "i-heroicons-truck",
|
||||
standardFiletype: deliveryTag?.id,
|
||||
standardFiletypeIsOptional: false,
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: String(currentYear),
|
||||
parent: folderByName.get("Eingangsrechnungen"),
|
||||
function: "incomingInvoices",
|
||||
year: currentYear,
|
||||
icon: "i-heroicons-inbox-arrow-down",
|
||||
standardFiletype: incomingInvoiceTag?.id,
|
||||
standardFiletypeIsOptional: false,
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
{
|
||||
tenant: tenantId,
|
||||
name: String(currentYear),
|
||||
parent: folderByName.get("Belege Bankeinzahlung"),
|
||||
function: "deposit",
|
||||
year: currentYear,
|
||||
icon: "i-heroicons-banknotes",
|
||||
isSystemUsed: true,
|
||||
updatedAt: timestamp,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!req.user?.user_id) {
|
||||
reply.code(401).send({ error: "Unauthorized" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const [currentUser] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, req.user.user_id))
|
||||
.limit(1);
|
||||
|
||||
if (!currentUser?.is_admin) {
|
||||
reply.code(403).send({ error: "Admin access required" });
|
||||
return null;
|
||||
}
|
||||
|
||||
return currentUser;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /admin/overview
|
||||
// -------------------------------------------------------------
|
||||
server.get("/admin/overview", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([
|
||||
server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
createdAt: tenants.createdAt,
|
||||
locked: tenants.locked,
|
||||
})
|
||||
.from(tenants),
|
||||
server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
multiTenant: authUsers.multiTenant,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers),
|
||||
server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
user_id: authProfiles.user_id,
|
||||
tenant_id: authProfiles.tenant_id,
|
||||
first_name: authProfiles.first_name,
|
||||
last_name: authProfiles.last_name,
|
||||
full_name: authProfiles.full_name,
|
||||
email: authProfiles.email,
|
||||
active: authProfiles.active,
|
||||
})
|
||||
.from(authProfiles),
|
||||
server.db
|
||||
.select()
|
||||
.from(authTenantUsers),
|
||||
server.db
|
||||
.select({
|
||||
id: authRoles.id,
|
||||
name: authRoles.name,
|
||||
description: authRoles.description,
|
||||
tenant_id: authRoles.tenant_id,
|
||||
})
|
||||
.from(authRoles),
|
||||
server.db
|
||||
.select({
|
||||
user_id: authUserRoles.user_id,
|
||||
role_id: authUserRoles.role_id,
|
||||
tenant_id: authUserRoles.tenant_id,
|
||||
})
|
||||
.from(authUserRoles),
|
||||
]);
|
||||
|
||||
const users = userRows.map((user) => {
|
||||
const profiles = profileRows.filter((profile) => profile.user_id === user.id);
|
||||
const memberships = membershipRows.filter((membership) => membership.user_id === user.id);
|
||||
const roleAssignments = roleAssignmentRows.filter((assignment) => assignment.user_id === user.id);
|
||||
const preferredProfile = profiles.find((profile) => profile.active) || profiles[0];
|
||||
const fallbackName = deriveNameFromEmail(user.email);
|
||||
|
||||
return {
|
||||
...user,
|
||||
display_name: preferredProfile?.full_name || user.email,
|
||||
profile_defaults: {
|
||||
first_name: preferredProfile?.first_name || fallbackName.first_name,
|
||||
last_name: preferredProfile?.last_name || fallbackName.last_name,
|
||||
},
|
||||
profiles,
|
||||
tenant_ids: memberships.map((membership) => membership.tenant_id),
|
||||
role_assignments: roleAssignments,
|
||||
};
|
||||
});
|
||||
|
||||
const tenantsWithCounts = tenantRows.map((tenant) => ({
|
||||
...tenant,
|
||||
user_count: membershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
|
||||
}));
|
||||
|
||||
return {
|
||||
users,
|
||||
tenants: tenantsWithCounts,
|
||||
roles: roleRows,
|
||||
unassignedProfiles: profileRows.filter((profile) => !profile.user_id),
|
||||
memberships: membershipRows,
|
||||
roleAssignments: roleAssignmentRows,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/overview:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/users
|
||||
// -------------------------------------------------------------
|
||||
server.post("/admin/users", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const body = req.body as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
is_admin?: boolean;
|
||||
multiTenant?: boolean;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
};
|
||||
|
||||
const email = body.email?.trim().toLowerCase();
|
||||
if (!email) {
|
||||
return reply.code(400).send({ error: "email required" });
|
||||
}
|
||||
|
||||
const existingUsers = await server.db
|
||||
.select({ id: authUsers.id })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existingUsers.length) {
|
||||
return reply.code(409).send({ error: "User with this email already exists" });
|
||||
}
|
||||
|
||||
const initialPassword = body.password?.trim() || generateRandomPassword(14);
|
||||
const passwordHash = await hashPassword(initialPassword);
|
||||
|
||||
const [createdUser] = await server.db
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
email,
|
||||
passwordHash,
|
||||
is_admin: Boolean(body.is_admin),
|
||||
multiTenant: typeof body.multiTenant === "boolean" ? body.multiTenant : true,
|
||||
must_change_password: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
multiTenant: authUsers.multiTenant,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
is_admin: authUsers.is_admin,
|
||||
});
|
||||
|
||||
return {
|
||||
user: createdUser,
|
||||
initialPassword,
|
||||
profile_defaults: {
|
||||
first_name: body.first_name?.trim() || deriveNameFromEmail(email).first_name,
|
||||
last_name: body.last_name?.trim() || deriveNameFromEmail(email).last_name,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/users:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/tenants
|
||||
// -------------------------------------------------------------
|
||||
server.post("/admin/tenants", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const body = req.body as {
|
||||
name?: string;
|
||||
short?: string;
|
||||
};
|
||||
|
||||
const name = body.name?.trim();
|
||||
const short = body.short?.trim();
|
||||
|
||||
if (!name || !short) {
|
||||
return reply.code(400).send({ error: "name and short required" });
|
||||
}
|
||||
|
||||
const [createdTenant] = await server.db
|
||||
.insert(tenants)
|
||||
.values({
|
||||
name,
|
||||
short,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: currentUser.id,
|
||||
})
|
||||
.returning({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
createdAt: tenants.createdAt,
|
||||
locked: tenants.locked,
|
||||
});
|
||||
|
||||
await createTenantSeeds(createdTenant.id, currentUser.id);
|
||||
|
||||
return { tenant: createdTenant };
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/tenants:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// PUT /admin/users/:user_id
|
||||
// -------------------------------------------------------------
|
||||
server.put("/admin/users/:user_id", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const { user_id } = req.params as { user_id: string };
|
||||
const body = req.body as {
|
||||
email?: string;
|
||||
multiTenant?: boolean;
|
||||
must_change_password?: boolean;
|
||||
is_admin?: boolean;
|
||||
};
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (typeof body.email === "string") updateData.email = body.email.trim().toLowerCase();
|
||||
if (typeof body.multiTenant === "boolean") updateData.multiTenant = body.multiTenant;
|
||||
if (typeof body.must_change_password === "boolean") updateData.must_change_password = body.must_change_password;
|
||||
if (typeof body.is_admin === "boolean") updateData.is_admin = body.is_admin;
|
||||
|
||||
const [updatedUser] = await server.db
|
||||
.update(authUsers)
|
||||
.set(updateData)
|
||||
.where(eq(authUsers.id, user_id))
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
multiTenant: authUsers.multiTenant,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
is_admin: authUsers.is_admin,
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
return { user: updatedUser };
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/users/:user_id:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// PUT /admin/tenants/:tenant_id
|
||||
// -------------------------------------------------------------
|
||||
server.put("/admin/tenants/:tenant_id", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const { tenant_id } = req.params as { tenant_id: string };
|
||||
const body = req.body as {
|
||||
name?: string;
|
||||
short?: string;
|
||||
};
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
updatedAt: new Date(),
|
||||
updatedBy: currentUser.id,
|
||||
};
|
||||
|
||||
if (typeof body.name === "string") updateData.name = body.name.trim();
|
||||
if (typeof body.short === "string") updateData.short = body.short.trim();
|
||||
|
||||
const [updatedTenant] = await server.db
|
||||
.update(tenants)
|
||||
.set(updateData)
|
||||
.where(eq(tenants.id, Number(tenant_id)))
|
||||
.returning({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
createdAt: tenants.createdAt,
|
||||
locked: tenants.locked,
|
||||
});
|
||||
|
||||
if (!updatedTenant) {
|
||||
return reply.code(404).send({ error: "Tenant not found" });
|
||||
}
|
||||
|
||||
return { tenant: updatedTenant };
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/tenants/:tenant_id:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// PUT /admin/users/:user_id/access
|
||||
// -------------------------------------------------------------
|
||||
server.put("/admin/users/:user_id/access", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const { user_id } = req.params as { user_id: string };
|
||||
const body = req.body as {
|
||||
tenant_ids?: number[];
|
||||
role_assignments?: { tenant_id: number; role_id: string }[];
|
||||
profile_defaults?: { first_name?: string; last_name?: string };
|
||||
profile_assignments?: { tenant_id: number; profile_id?: string | null }[];
|
||||
};
|
||||
|
||||
const tenantIds = Array.from(new Set((body.tenant_ids || []).map((tenantId) => Number(tenantId)).filter(Boolean)));
|
||||
const requestedAssignments = (body.role_assignments || [])
|
||||
.map((assignment) => ({
|
||||
tenant_id: Number(assignment.tenant_id),
|
||||
role_id: assignment.role_id,
|
||||
}))
|
||||
.filter((assignment) => assignment.tenant_id && assignment.role_id && tenantIds.includes(assignment.tenant_id));
|
||||
|
||||
const [targetUser] = await server.db
|
||||
.select({ id: authUsers.id, email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, user_id))
|
||||
.limit(1);
|
||||
|
||||
if (!targetUser) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
const availableRoles = requestedAssignments.length
|
||||
? await server.db
|
||||
.select({
|
||||
id: authRoles.id,
|
||||
tenant_id: authRoles.tenant_id,
|
||||
})
|
||||
.from(authRoles)
|
||||
.where(
|
||||
inArray(
|
||||
authRoles.id,
|
||||
requestedAssignments.map((assignment) => assignment.role_id)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const validRoleIds = new Set(
|
||||
availableRoles
|
||||
.filter((role) =>
|
||||
role.tenant_id === null ||
|
||||
requestedAssignments.some((assignment) => assignment.role_id === role.id && assignment.tenant_id === role.tenant_id)
|
||||
)
|
||||
.map((role) => role.id)
|
||||
);
|
||||
|
||||
const validAssignments = requestedAssignments.filter((assignment) => validRoleIds.has(assignment.role_id));
|
||||
const existingMemberships = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(eq(authTenantUsers.user_id, user_id));
|
||||
const removedTenantIds = existingMemberships
|
||||
.map((membership) => membership.tenant_id)
|
||||
.filter((tenantId) => !tenantIds.includes(tenantId));
|
||||
|
||||
const existingUserProfiles = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
tenant_id: authProfiles.tenant_id,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.user_id, user_id));
|
||||
|
||||
const unassignedProfiles = tenantIds.length
|
||||
? await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
tenant_id: authProfiles.tenant_id,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
inArray(authProfiles.tenant_id, tenantIds),
|
||||
isNull(authProfiles.user_id)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const fallbackName = deriveNameFromEmail(targetUser.email);
|
||||
const profileDefaults = {
|
||||
first_name: body.profile_defaults?.first_name?.trim() || fallbackName.first_name,
|
||||
last_name: body.profile_defaults?.last_name?.trim() || fallbackName.last_name,
|
||||
};
|
||||
const requestedProfileAssignments = new Map<number, string>(
|
||||
(body.profile_assignments || [])
|
||||
.filter((assignment) => assignment?.tenant_id && assignment.profile_id)
|
||||
.map((assignment) => [Number(assignment.tenant_id), String(assignment.profile_id)])
|
||||
);
|
||||
|
||||
await server.db
|
||||
.delete(authUserRoles)
|
||||
.where(eq(authUserRoles.user_id, user_id));
|
||||
|
||||
await server.db
|
||||
.delete(authTenantUsers)
|
||||
.where(eq(authTenantUsers.user_id, user_id));
|
||||
|
||||
if (tenantIds.length) {
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
.values(
|
||||
tenantIds.map((tenantId) => ({
|
||||
tenant_id: tenantId,
|
||||
user_id,
|
||||
created_by: currentUser.id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (validAssignments.length) {
|
||||
await server.db
|
||||
.insert(authUserRoles)
|
||||
.values(
|
||||
validAssignments.map((assignment) => ({
|
||||
user_id,
|
||||
tenant_id: assignment.tenant_id,
|
||||
role_id: assignment.role_id,
|
||||
created_by: currentUser.id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (removedTenantIds.length) {
|
||||
await server.db
|
||||
.update(authProfiles)
|
||||
.set({ user_id: null })
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, user_id),
|
||||
inArray(authProfiles.tenant_id, removedTenantIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const existingProfileTenantIds = new Set(existingUserProfiles.map((profile) => profile.tenant_id));
|
||||
|
||||
for (const tenantId of tenantIds) {
|
||||
if (existingProfileTenantIds.has(tenantId)) continue;
|
||||
|
||||
const requestedProfileId = requestedProfileAssignments.get(tenantId);
|
||||
const freeProfile = requestedProfileId
|
||||
? unassignedProfiles.find((profile) => profile.id === requestedProfileId && profile.tenant_id === tenantId)
|
||||
: null;
|
||||
|
||||
if (freeProfile) {
|
||||
await server.db
|
||||
.update(authProfiles)
|
||||
.set({ user_id })
|
||||
.where(eq(authProfiles.id, freeProfile.id));
|
||||
continue;
|
||||
}
|
||||
|
||||
await server.db
|
||||
.insert(authProfiles)
|
||||
.values({
|
||||
user_id,
|
||||
tenant_id: tenantId,
|
||||
first_name: profileDefaults.first_name,
|
||||
last_name: profileDefaults.last_name,
|
||||
email: targetUser.email,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tenant_ids: tenantIds,
|
||||
role_assignments: validAssignments,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/users/:user_id/access:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/add-user-to-tenant
|
||||
// -------------------------------------------------------------
|
||||
server.post("/admin/add-user-to-tenant", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const body = req.body as {
|
||||
user_id: string;
|
||||
tenant_id: number;
|
||||
@@ -44,11 +786,10 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
// @ts-ignore
|
||||
.values({
|
||||
user_id: body.user_id,
|
||||
tenantId: body.tenant_id,
|
||||
role: body.role ?? "member",
|
||||
tenant_id: body.tenant_id,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
|
||||
return { success: true, mode };
|
||||
@@ -65,6 +806,9 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
// -------------------------------------------------------------
|
||||
server.get("/admin/user-tenants/:user_id", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const { user_id } = req.params as { user_id: string };
|
||||
|
||||
if (!user_id) {
|
||||
@@ -94,6 +838,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
short: tenants.short,
|
||||
locked: tenants.locked,
|
||||
numberRanges: tenants.numberRanges,
|
||||
accountChart: tenants.accountChart,
|
||||
extraModules: tenants.extraModules,
|
||||
})
|
||||
.from(authTenantUsers)
|
||||
|
||||
@@ -1,11 +1,60 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import bcrypt from "bcrypt"
|
||||
import { eq } from "drizzle-orm"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { secrets } from "../../utils/secrets"
|
||||
|
||||
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
|
||||
|
||||
export default async function authRoutesAuthenticated(server: FastifyInstance) {
|
||||
|
||||
server.post("/auth/refresh", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Refresh JWT for current authenticated user",
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
required: ["token"],
|
||||
},
|
||||
401: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
required: ["error"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
if (!req.user?.user_id) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: req.user.user_id,
|
||||
email: req.user.email,
|
||||
tenant_id: req.user.tenant_id,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: "6h" }
|
||||
)
|
||||
|
||||
reply.setCookie("token", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 6,
|
||||
})
|
||||
|
||||
return { token }
|
||||
})
|
||||
|
||||
server.post("/auth/password/change", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -137,7 +137,7 @@ export default async function authRoutes(server: FastifyInstance) {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 3,
|
||||
maxAge: 60 * 60 * 6,
|
||||
});
|
||||
|
||||
return { token };
|
||||
|
||||
@@ -31,6 +31,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
@@ -51,9 +52,12 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
locked: tenants.locked,
|
||||
features: tenants.features,
|
||||
extraModules: tenants.extraModules,
|
||||
businessInfo: tenants.businessInfo,
|
||||
numberRanges: tenants.numberRanges,
|
||||
accountChart: tenants.accountChart,
|
||||
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
|
||||
dokuboxkey: tenants.dokuboxkey,
|
||||
standardEmailForInvoices: tenants.standardEmailForInvoices,
|
||||
standardPaymentDays: tenants.standardPaymentDays,
|
||||
|
||||
@@ -4,10 +4,19 @@ import dayjs from "dayjs"
|
||||
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { insertHistoryItem } from "../utils/history"
|
||||
import { decrypt, encrypt } from "../utils/crypt"
|
||||
import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
|
||||
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
bankstatements,
|
||||
createddocuments,
|
||||
customers,
|
||||
entitybankaccounts,
|
||||
incominginvoices,
|
||||
statementallocations,
|
||||
vendors,
|
||||
} from "../../db/schema"
|
||||
|
||||
import {
|
||||
@@ -17,6 +26,520 @@ import {
|
||||
|
||||
|
||||
export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const normalizeIban = (value?: string | null) =>
|
||||
String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||
|
||||
const normalizeName = (value?: string | null) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9äöüß]+/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
|
||||
const pickPartnerBankData = (statement: any, partnerType: "customer" | "vendor") => {
|
||||
if (!statement) return null
|
||||
|
||||
const prefersDebit = partnerType === "customer"
|
||||
? Number(statement.amount) >= 0
|
||||
: Number(statement.amount) > 0
|
||||
|
||||
const primary = prefersDebit
|
||||
? { iban: statement.debIban }
|
||||
: { iban: statement.credIban }
|
||||
const fallback = prefersDebit
|
||||
? { iban: statement.credIban }
|
||||
: { iban: statement.debIban }
|
||||
|
||||
const primaryIban = normalizeIban(primary.iban)
|
||||
if (primaryIban) {
|
||||
return {
|
||||
iban: primaryIban,
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackIban = normalizeIban(fallback.iban)
|
||||
if (fallbackIban) {
|
||||
return {
|
||||
iban: fallbackIban,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const pickPartnerReference = (statement: any, partnerType: "customer" | "vendor") => {
|
||||
if (!statement) return null
|
||||
|
||||
const prefersDebit = partnerType === "customer"
|
||||
? Number(statement.amount) >= 0
|
||||
: Number(statement.amount) > 0
|
||||
|
||||
const primary = prefersDebit
|
||||
? { iban: statement.debIban, name: statement.debName }
|
||||
: { iban: statement.credIban, name: statement.credName }
|
||||
const fallback = prefersDebit
|
||||
? { iban: statement.credIban, name: statement.credName }
|
||||
: { iban: statement.debIban, name: statement.debName }
|
||||
|
||||
return {
|
||||
iban: normalizeIban(primary.iban) || normalizeIban(fallback.iban) || null,
|
||||
name: String(primary.name || fallback.name || "").trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
const mergePartnerIban = (infoData: Record<string, any>, iban: string, bankAccountId?: number | null) => {
|
||||
if (!iban && !bankAccountId) return infoData || {}
|
||||
const info = infoData && typeof infoData === "object" ? { ...infoData } : {}
|
||||
|
||||
if (iban) {
|
||||
const existing = Array.isArray(info.bankingIbans) ? info.bankingIbans : []
|
||||
const merged = [...new Set([...existing.map((i: string) => normalizeIban(i)), iban])]
|
||||
info.bankingIbans = merged
|
||||
if (!info.bankingIban) info.bankingIban = iban
|
||||
}
|
||||
|
||||
if (bankAccountId) {
|
||||
const existingIds = Array.isArray(info.bankAccountIds) ? info.bankAccountIds : []
|
||||
if (!existingIds.includes(bankAccountId)) {
|
||||
info.bankAccountIds = [...existingIds, bankAccountId]
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
const ibanLengthByCountry: Record<string, number> = {
|
||||
DE: 22,
|
||||
AT: 20,
|
||||
CH: 21,
|
||||
NL: 18,
|
||||
BE: 16,
|
||||
FR: 27,
|
||||
ES: 24,
|
||||
IT: 27,
|
||||
LU: 20,
|
||||
}
|
||||
|
||||
const isValidIbanLocal = (iban: string) => {
|
||||
const normalized = normalizeIban(iban)
|
||||
if (!normalized || normalized.length < 15 || normalized.length > 34) return false
|
||||
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(normalized)) return false
|
||||
|
||||
const country = normalized.slice(0, 2)
|
||||
const expectedLength = ibanLengthByCountry[country]
|
||||
if (expectedLength && normalized.length !== expectedLength) return false
|
||||
|
||||
const rearranged = normalized.slice(4) + normalized.slice(0, 4)
|
||||
let numeric = ""
|
||||
for (const ch of rearranged) {
|
||||
if (ch >= "A" && ch <= "Z") numeric += (ch.charCodeAt(0) - 55).toString()
|
||||
else numeric += ch
|
||||
}
|
||||
|
||||
let remainder = 0
|
||||
for (const digit of numeric) {
|
||||
remainder = (remainder * 10 + Number(digit)) % 97
|
||||
}
|
||||
|
||||
return remainder === 1
|
||||
}
|
||||
|
||||
const resolveGermanBankDataFromIbanLocal = (iban: string) => {
|
||||
const normalized = normalizeIban(iban)
|
||||
if (!isValidIbanLocal(normalized)) return null
|
||||
|
||||
// Für DE-IBANs kann die BLZ aus Position 5-12 lokal gelesen werden.
|
||||
if (normalized.startsWith("DE") && normalized.length === 22) {
|
||||
const bankCode = normalized.slice(4, 12)
|
||||
const bankName = DE_BANK_CODE_TO_NAME[bankCode] || `Unbekannt (BLZ ${bankCode})`
|
||||
const bic = DE_BANK_CODE_TO_BIC[bankCode] || null
|
||||
return {
|
||||
bankName,
|
||||
bic,
|
||||
bankCode,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveEntityBankAccountId = async (
|
||||
tenantId: number,
|
||||
userId: string,
|
||||
iban: string
|
||||
) => {
|
||||
const normalizedIban = normalizeIban(iban)
|
||||
if (!normalizedIban) return null
|
||||
|
||||
const bankData = resolveGermanBankDataFromIbanLocal(normalizedIban)
|
||||
|
||||
const allAccounts = await server.db
|
||||
.select({
|
||||
id: entitybankaccounts.id,
|
||||
ibanEncrypted: entitybankaccounts.ibanEncrypted,
|
||||
bankNameEncrypted: entitybankaccounts.bankNameEncrypted,
|
||||
bicEncrypted: entitybankaccounts.bicEncrypted,
|
||||
})
|
||||
.from(entitybankaccounts)
|
||||
.where(eq(entitybankaccounts.tenant, tenantId))
|
||||
|
||||
const existing = allAccounts.find((row) => {
|
||||
if (!row.ibanEncrypted) return false
|
||||
try {
|
||||
const decryptedIban = decrypt(row.ibanEncrypted as any)
|
||||
return normalizeIban(decryptedIban) === normalizedIban
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (existing?.id) {
|
||||
if (bankData) {
|
||||
let currentBankName = ""
|
||||
let currentBic = ""
|
||||
try {
|
||||
currentBankName = String(decrypt(existing.bankNameEncrypted as any) || "").trim()
|
||||
} catch {
|
||||
currentBankName = ""
|
||||
}
|
||||
try {
|
||||
currentBic = String(decrypt((existing as any).bicEncrypted as any) || "").trim()
|
||||
} catch {
|
||||
currentBic = ""
|
||||
}
|
||||
|
||||
const nextBankName = bankData?.bankName || "Unbekannt"
|
||||
const nextBic = bankData?.bic || "UNBEKANNT"
|
||||
if (currentBankName !== nextBankName || currentBic !== nextBic) {
|
||||
await server.db
|
||||
.update(entitybankaccounts)
|
||||
.set({
|
||||
bankNameEncrypted: encrypt(nextBankName),
|
||||
bicEncrypted: encrypt(nextBic),
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(and(eq(entitybankaccounts.id, Number(existing.id)), eq(entitybankaccounts.tenant, tenantId)))
|
||||
}
|
||||
}
|
||||
|
||||
return Number(existing.id)
|
||||
}
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(entitybankaccounts)
|
||||
.values({
|
||||
tenant: tenantId,
|
||||
ibanEncrypted: encrypt(normalizedIban),
|
||||
bicEncrypted: encrypt(bankData?.bic || "UNBEKANNT"),
|
||||
bankNameEncrypted: encrypt(bankData?.bankName || "Unbekannt"),
|
||||
description: "Automatisch aus Bankbuchung übernommen",
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
})
|
||||
.returning({ id: entitybankaccounts.id })
|
||||
|
||||
return created?.id ? Number(created.id) : null
|
||||
}
|
||||
|
||||
server.get("/banking/iban/:iban", async (req, reply) => {
|
||||
try {
|
||||
const { iban } = req.params as { iban: string }
|
||||
const normalized = normalizeIban(iban)
|
||||
if (!normalized) {
|
||||
return reply.code(400).send({ error: "IBAN missing" })
|
||||
}
|
||||
|
||||
const valid = isValidIbanLocal(normalized)
|
||||
const bankData = resolveGermanBankDataFromIbanLocal(normalized)
|
||||
|
||||
return reply.send({
|
||||
iban: normalized,
|
||||
valid,
|
||||
bic: bankData?.bic || null,
|
||||
bankName: bankData?.bankName || null,
|
||||
bankCode: bankData?.bankCode || null,
|
||||
})
|
||||
} catch (err) {
|
||||
server.log.error(err)
|
||||
return reply.code(500).send({ error: "Failed to resolve IBAN data" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/banking/statements/:id/suggestions", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const statementId = Number(id)
|
||||
if (!statementId) return reply.code(400).send({ error: "Invalid statement id" })
|
||||
|
||||
const [statement] = await server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, req.user.tenant_id)))
|
||||
.limit(1)
|
||||
|
||||
if (!statement) return reply.code(404).send({ error: "Statement not found" })
|
||||
|
||||
const partnerType: "customer" | "vendor" = Number(statement.amount) >= 0 ? "customer" : "vendor"
|
||||
const partnerRef = pickPartnerReference(statement, partnerType)
|
||||
|
||||
const suggestions: Array<Record<string, any>> = []
|
||||
let matchedBankAccountId: number | null = null
|
||||
|
||||
if (partnerRef?.iban) {
|
||||
const allAccounts = await server.db
|
||||
.select({
|
||||
id: entitybankaccounts.id,
|
||||
ibanEncrypted: entitybankaccounts.ibanEncrypted,
|
||||
})
|
||||
.from(entitybankaccounts)
|
||||
.where(eq(entitybankaccounts.tenant, req.user.tenant_id))
|
||||
|
||||
const matchingAccount = allAccounts.find((row) => {
|
||||
if (!row.ibanEncrypted) return false
|
||||
try {
|
||||
return normalizeIban(decrypt(row.ibanEncrypted as any)) === partnerRef.iban
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
matchedBankAccountId = matchingAccount?.id ? Number(matchingAccount.id) : null
|
||||
}
|
||||
|
||||
if (partnerType === "customer") {
|
||||
const customerRows = await server.db
|
||||
.select({
|
||||
id: customers.id,
|
||||
name: customers.name,
|
||||
customerNumber: customers.customerNumber,
|
||||
infoData: customers.infoData,
|
||||
})
|
||||
.from(customers)
|
||||
.where(and(eq(customers.tenant, req.user.tenant_id), eq(customers.archived, false)))
|
||||
|
||||
for (const row of customerRows) {
|
||||
const infoData = row.infoData && typeof row.infoData === "object" ? row.infoData as Record<string, any> : {}
|
||||
const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : []
|
||||
const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map((iban) => normalizeIban(String(iban))) : []
|
||||
const normalizedEntityName = normalizeName(row.name)
|
||||
const normalizedStatementName = normalizeName(partnerRef?.name)
|
||||
|
||||
const matchesBankAccountId = matchedBankAccountId ? bankAccountIds.includes(matchedBankAccountId) : false
|
||||
const matchesIban = partnerRef?.iban ? bankingIbans.includes(partnerRef.iban) : false
|
||||
const exactNameMatch = normalizedEntityName && normalizedStatementName && normalizedEntityName === normalizedStatementName
|
||||
const partialNameMatch = normalizedEntityName && normalizedStatementName
|
||||
? normalizedEntityName.includes(normalizedStatementName) || normalizedStatementName.includes(normalizedEntityName)
|
||||
: false
|
||||
|
||||
let score = 0
|
||||
let reason = ""
|
||||
|
||||
if (matchesBankAccountId && matchesIban) {
|
||||
score = 100
|
||||
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
|
||||
} else if (matchesBankAccountId) {
|
||||
score = 95
|
||||
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
||||
} else if (matchesIban) {
|
||||
score = 90
|
||||
reason = "IBAN wurde bereits bei diesem Kunden verwendet"
|
||||
} else if (exactNameMatch) {
|
||||
score = 60
|
||||
reason = "Name passt exakt zur Buchung"
|
||||
} else if (partialNameMatch) {
|
||||
score = 45
|
||||
reason = "Name aehnelt der Buchung"
|
||||
}
|
||||
|
||||
if (!score) continue
|
||||
|
||||
suggestions.push({
|
||||
type: "customer",
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
number: row.customerNumber,
|
||||
score,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const vendorRows = await server.db
|
||||
.select({
|
||||
id: vendors.id,
|
||||
name: vendors.name,
|
||||
vendorNumber: vendors.vendorNumber,
|
||||
infoData: vendors.infoData,
|
||||
})
|
||||
.from(vendors)
|
||||
.where(and(eq(vendors.tenant, req.user.tenant_id), eq(vendors.archived, false)))
|
||||
|
||||
for (const row of vendorRows) {
|
||||
const infoData = row.infoData && typeof row.infoData === "object" ? row.infoData as Record<string, any> : {}
|
||||
const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : []
|
||||
const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map((iban) => normalizeIban(String(iban))) : []
|
||||
const normalizedEntityName = normalizeName(row.name)
|
||||
const normalizedStatementName = normalizeName(partnerRef?.name)
|
||||
|
||||
const matchesBankAccountId = matchedBankAccountId ? bankAccountIds.includes(matchedBankAccountId) : false
|
||||
const matchesIban = partnerRef?.iban ? bankingIbans.includes(partnerRef.iban) : false
|
||||
const exactNameMatch = normalizedEntityName && normalizedStatementName && normalizedEntityName === normalizedStatementName
|
||||
const partialNameMatch = normalizedEntityName && normalizedStatementName
|
||||
? normalizedEntityName.includes(normalizedStatementName) || normalizedStatementName.includes(normalizedEntityName)
|
||||
: false
|
||||
|
||||
let score = 0
|
||||
let reason = ""
|
||||
|
||||
if (matchesBankAccountId && matchesIban) {
|
||||
score = 100
|
||||
reason = "IBAN und hinterlegte Bankverbindung stimmen ueberein"
|
||||
} else if (matchesBankAccountId) {
|
||||
score = 95
|
||||
reason = "Hinterlegte Bankverbindung passt zur IBAN"
|
||||
} else if (matchesIban) {
|
||||
score = 90
|
||||
reason = "IBAN wurde bereits bei diesem Lieferanten verwendet"
|
||||
} else if (exactNameMatch) {
|
||||
score = 60
|
||||
reason = "Name passt exakt zur Buchung"
|
||||
} else if (partialNameMatch) {
|
||||
score = 45
|
||||
reason = "Name aehnelt der Buchung"
|
||||
}
|
||||
|
||||
if (!score) continue
|
||||
|
||||
suggestions.push({
|
||||
type: "vendor",
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
number: row.vendorNumber,
|
||||
score,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.sort((a, b) => b.score - a.score || String(a.name).localeCompare(String(b.name), "de"))
|
||||
|
||||
return reply.send({
|
||||
partnerType,
|
||||
partnerName: partnerRef?.name || null,
|
||||
partnerIban: partnerRef?.iban || null,
|
||||
suggestions: suggestions.slice(0, 5),
|
||||
})
|
||||
} catch (err) {
|
||||
server.log.error(err)
|
||||
return reply.code(500).send({ error: "Failed to load statement suggestions" })
|
||||
}
|
||||
})
|
||||
|
||||
const assignIbanFromStatementToCustomer = async (tenantId: number, userId: string, statementId: number, createdDocumentId?: number) => {
|
||||
if (!createdDocumentId) return
|
||||
|
||||
const [statement] = await server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!statement) return
|
||||
|
||||
const [doc] = await server.db
|
||||
.select({ customer: createddocuments.customer })
|
||||
.from(createddocuments)
|
||||
.where(and(eq(createddocuments.id, createdDocumentId), eq(createddocuments.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
const customerId = doc?.customer
|
||||
if (!customerId) return
|
||||
|
||||
const partnerBank = pickPartnerBankData(statement, "customer")
|
||||
if (!partnerBank?.iban) return
|
||||
|
||||
const [customer] = await server.db
|
||||
.select({ id: customers.id, infoData: customers.infoData })
|
||||
.from(customers)
|
||||
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!customer) return
|
||||
|
||||
const bankAccountId = await resolveEntityBankAccountId(
|
||||
tenantId,
|
||||
userId,
|
||||
partnerBank.iban
|
||||
)
|
||||
|
||||
const newInfoData = mergePartnerIban(
|
||||
(customer.infoData || {}) as Record<string, any>,
|
||||
partnerBank.iban,
|
||||
bankAccountId
|
||||
)
|
||||
await server.db
|
||||
.update(customers)
|
||||
.set({
|
||||
infoData: newInfoData,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
|
||||
}
|
||||
|
||||
const assignIbanFromStatementToVendor = async (tenantId: number, userId: string, statementId: number, incomingInvoiceId?: number) => {
|
||||
if (!incomingInvoiceId) return
|
||||
|
||||
const [statement] = await server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!statement) return
|
||||
|
||||
const [invoice] = await server.db
|
||||
.select({ vendor: incominginvoices.vendor })
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, incomingInvoiceId), eq(incominginvoices.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
const vendorId = invoice?.vendor
|
||||
if (!vendorId) return
|
||||
|
||||
const partnerBank = pickPartnerBankData(statement, "vendor")
|
||||
if (!partnerBank?.iban) return
|
||||
|
||||
const [vendor] = await server.db
|
||||
.select({ id: vendors.id, infoData: vendors.infoData })
|
||||
.from(vendors)
|
||||
.where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!vendor) return
|
||||
|
||||
const bankAccountId = await resolveEntityBankAccountId(
|
||||
tenantId,
|
||||
userId,
|
||||
partnerBank.iban
|
||||
)
|
||||
|
||||
const newInfoData = mergePartnerIban(
|
||||
(vendor.infoData || {}) as Record<string, any>,
|
||||
partnerBank.iban,
|
||||
bankAccountId
|
||||
)
|
||||
await server.db
|
||||
.update(vendors)
|
||||
.set({
|
||||
infoData: newInfoData,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId)))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🔐 GoCardLess Token Handling
|
||||
@@ -171,9 +694,35 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
|
||||
const createdRecord = inserted[0]
|
||||
|
||||
if (createdRecord?.createddocument) {
|
||||
try {
|
||||
await assignIbanFromStatementToCustomer(
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
Number(createdRecord.bankstatement),
|
||||
Number(createdRecord.createddocument)
|
||||
)
|
||||
} catch (err) {
|
||||
server.log.warn({ err, allocationId: createdRecord.id }, "Konnte IBAN nicht automatisch beim Kunden hinterlegen")
|
||||
}
|
||||
}
|
||||
|
||||
if (createdRecord?.incominginvoice) {
|
||||
try {
|
||||
await assignIbanFromStatementToVendor(
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
Number(createdRecord.bankstatement),
|
||||
Number(createdRecord.incominginvoice)
|
||||
)
|
||||
} catch (err) {
|
||||
server.log.warn({ err, allocationId: createdRecord.id }, "Konnte IBAN nicht automatisch beim Lieferanten hinterlegen")
|
||||
}
|
||||
}
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: createdRecord.id,
|
||||
entityId: Number(createdRecord.bankstatement),
|
||||
action: "created",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
@@ -216,7 +765,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: id,
|
||||
entityId: Number(old.bankstatement),
|
||||
action: "deleted",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
|
||||
58
backend/src/routes/devices/management.ts
Normal file
58
backend/src/routes/devices/management.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../../db"; // <--- PFAD ZUR DB INSTANZ ANPASSEN
|
||||
import { devices } from "../../../db/schema";
|
||||
|
||||
// Definition, was wir vom ESP32 erwarten
|
||||
interface HealthBody {
|
||||
terminal_id: string;
|
||||
ip_address?: string;
|
||||
wifi_rssi?: number;
|
||||
uptime_seconds?: number;
|
||||
heap_free?: number;
|
||||
[key: string]: any; // Erlaubt weitere Felder
|
||||
}
|
||||
|
||||
export default async function devicesManagementRoutes(server: FastifyInstance) {
|
||||
server.post<{ Body: HealthBody }>(
|
||||
"/health",
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const data = req.body;
|
||||
|
||||
// 1. Validierung: Haben wir eine ID?
|
||||
if (!data.terminal_id) {
|
||||
console.warn("Health Check ohne terminal_id empfangen:", data);
|
||||
return reply.code(400).send({ error: "terminal_id missing" });
|
||||
}
|
||||
|
||||
console.log(`Health Ping von Device ${data.terminal_id}`, data);
|
||||
|
||||
// 2. Datenbank Update
|
||||
// Wir suchen das Gerät mit der passenden externalId
|
||||
const result = await server.db
|
||||
.update(devices)
|
||||
.set({
|
||||
lastSeen: new Date(), // Setzt Zeit auf JETZT
|
||||
lastDebugInfo: data // Speichert das ganze JSON
|
||||
})
|
||||
.where(eq(devices.externalId, data.terminal_id))
|
||||
.returning({ id: devices.id }); // Gibt ID zurück, falls gefunden
|
||||
|
||||
// 3. Checken ob Gerät gefunden wurde
|
||||
if (result.length === 0) {
|
||||
console.warn(`Unbekanntes Terminal versucht Health Check: ${data.terminal_id}`);
|
||||
// Optional: 404 senden oder ignorieren (Sicherheit)
|
||||
return reply.code(404).send({ error: "Device not found" });
|
||||
}
|
||||
|
||||
// Alles OK
|
||||
return reply.code(200).send({ status: "ok" });
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Health Check Error:", err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,39 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {and, desc, eq} from "drizzle-orm";
|
||||
import {authProfiles, devices, stafftimeevents} from "../../../db/schema";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
|
||||
|
||||
export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||
server.post(
|
||||
"/rfid/createevent/:terminal_id",
|
||||
async (req, reply) => {
|
||||
try {
|
||||
// 1. Timestamp aus dem Body holen (optional)
|
||||
const { rfid_id, timestamp } = req.body as {
|
||||
rfid_id: string,
|
||||
timestamp?: number // Kann undefined sein (Live) oder Zahl (Offline)
|
||||
};
|
||||
|
||||
const {rfid_id} = req.body as {rfid_id: string};
|
||||
const {terminal_id} = req.params as {terminal_id: string};
|
||||
const { terminal_id } = req.params as { terminal_id: string };
|
||||
|
||||
if(!rfid_id ||!terminal_id) {
|
||||
if (!rfid_id || !terminal_id) {
|
||||
console.log(`Missing Params`);
|
||||
return reply.code(400).send(`Missing Params`)
|
||||
return reply.code(400).send(`Missing Params`);
|
||||
}
|
||||
|
||||
// 2. Gerät suchen
|
||||
const device = await server.db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(
|
||||
eq(devices.externalId, terminal_id)
|
||||
|
||||
)
|
||||
.where(eq(devices.externalId, terminal_id))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if(!device) {
|
||||
if (!device) {
|
||||
console.log(`Device ${terminal_id} not found`);
|
||||
return reply.code(400).send(`Device ${terminal_id} not found`)
|
||||
|
||||
return reply.code(400).send(`Device ${terminal_id} not found`);
|
||||
}
|
||||
|
||||
// 3. User-Profil suchen
|
||||
const profile = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
@@ -44,55 +46,56 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if(!profile) {
|
||||
if (!profile) {
|
||||
console.log(`Profile for Token ${rfid_id} not found`);
|
||||
return reply.code(400).send(`Profile for Token ${rfid_id} not found`)
|
||||
|
||||
return reply.code(400).send(`Profile for Token ${rfid_id} not found`);
|
||||
}
|
||||
|
||||
// 4. Letztes Event suchen (für Status-Toggle Work Start/End)
|
||||
const lastEvent = await server.db
|
||||
.select()
|
||||
.from(stafftimeevents)
|
||||
.where(
|
||||
eq(stafftimeevents.user_id, profile.user_id)
|
||||
)
|
||||
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
|
||||
.where(eq(stafftimeevents.user_id, profile.user_id))
|
||||
.orderBy(desc(stafftimeevents.eventtime))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
console.log(lastEvent)
|
||||
// 5. Zeitstempel Logik (WICHTIG!)
|
||||
// Der ESP32 sendet Unix-Timestamp in SEKUNDEN. JS braucht MILLISEKUNDEN.
|
||||
// Wenn kein Timestamp kommt (0 oder undefined), nehmen wir JETZT.
|
||||
const actualEventTime = (timestamp && timestamp > 0)
|
||||
? new Date(timestamp * 1000)
|
||||
: new Date();
|
||||
|
||||
// 6. Event Typ bestimmen (Toggle Logik)
|
||||
// Falls noch nie gestempelt wurde (lastEvent undefined), fangen wir mit start an.
|
||||
const nextEventType = (lastEvent?.eventtype === "work_start")
|
||||
? "work_end"
|
||||
: "work_start";
|
||||
|
||||
const dataToInsert = {
|
||||
tenant_id: device.tenant,
|
||||
user_id: profile.user_id,
|
||||
actortype: "system",
|
||||
eventtime: new Date(),
|
||||
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
|
||||
source: "WEB"
|
||||
}
|
||||
eventtime: actualEventTime, // Hier nutzen wir die berechnete Zeit
|
||||
eventtype: nextEventType,
|
||||
source: "TERMINAL" // Habe ich von WEB auf TERMINAL geändert (optional)
|
||||
};
|
||||
|
||||
console.log(dataToInsert)
|
||||
console.log(`New Event for ${profile.user_id}: ${nextEventType} @ ${actualEventTime.toISOString()}`);
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(dataToInsert)
|
||||
.returning()
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
|
||||
return created
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
return reply.code(400).send({ error: err.message })
|
||||
console.error(err);
|
||||
return reply.code(400).send({ error: err.message });
|
||||
}
|
||||
|
||||
|
||||
|
||||
console.log(req.body)
|
||||
|
||||
return
|
||||
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import {insertHistoryItem} from "../utils/history";
|
||||
import {buildExportZip} from "../utils/export/datev";
|
||||
import {s3} from "../utils/s3";
|
||||
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
|
||||
@@ -9,60 +7,64 @@ import dayjs from "dayjs";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {secrets} from "../utils/secrets";
|
||||
import {createSEPAExport} from "../utils/export/sepa";
|
||||
import {generatedexports} from "../../db/schema";
|
||||
import {eq} from "drizzle-orm";
|
||||
|
||||
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
|
||||
console.log(startDate,endDate,beraternr,mandantennr)
|
||||
try {
|
||||
console.log(startDate,endDate,beraternr,mandantennr)
|
||||
|
||||
// 1) ZIP erzeugen
|
||||
const buffer = await buildExportZip(server,req.user.tenant_id, startDate, endDate, beraternr, mandantennr)
|
||||
console.log("ZIP created")
|
||||
console.log(buffer)
|
||||
// 1) ZIP erzeugen
|
||||
const buffer = await buildExportZip(server,req.user.tenant_id, startDate, endDate, beraternr, mandantennr)
|
||||
console.log("ZIP created")
|
||||
console.log(buffer)
|
||||
|
||||
// 2) Dateiname & Key festlegen
|
||||
const fileKey = `${req.user.tenant_id}/exports/Export_${dayjs(startDate).format("YYYY-MM-DD")}_${dayjs(endDate).format("YYYY-MM-DD")}_${randomUUID()}.zip`
|
||||
console.log(fileKey)
|
||||
// 2) Dateiname & Key festlegen
|
||||
const fileKey = `${req.user.tenant_id}/exports/Export_${dayjs(startDate).format("YYYY-MM-DD")}_${dayjs(endDate).format("YYYY-MM-DD")}_${randomUUID()}.zip`
|
||||
console.log(fileKey)
|
||||
|
||||
// 3) In S3 hochladen
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
Body: buffer,
|
||||
ContentType: "application/zip",
|
||||
})
|
||||
)
|
||||
// 3) In S3 hochladen
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
Body: buffer,
|
||||
ContentType: "application/zip",
|
||||
})
|
||||
)
|
||||
|
||||
// 4) Presigned URL erzeugen (24h gültig)
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
}),
|
||||
{ expiresIn: 60 * 60 * 24 }
|
||||
)
|
||||
// 4) Presigned URL erzeugen (24h gültig)
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
}),
|
||||
{ expiresIn: 60 * 60 * 24 }
|
||||
)
|
||||
|
||||
console.log(url)
|
||||
console.log(url)
|
||||
|
||||
// 5) In Haupt-DB speichern
|
||||
const inserted = await server.db
|
||||
.insert(generatedexports)
|
||||
.values({
|
||||
tenantId: req.user.tenant_id,
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
validUntil: dayjs().add(24, "hours").toDate(),
|
||||
filePath: fileKey,
|
||||
url,
|
||||
type: "datev",
|
||||
})
|
||||
.returning()
|
||||
|
||||
console.log(inserted[0])
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
// 5) In Supabase-DB speichern
|
||||
const { data, error } = await server.supabase
|
||||
.from("exports")
|
||||
.insert([
|
||||
{
|
||||
tenant_id: req.user.tenant_id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
valid_until: dayjs().add(24,"hours").toISOString(),
|
||||
file_path: fileKey,
|
||||
url: url,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
console.log(data)
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -114,9 +116,22 @@ export default async function exportRoutes(server: FastifyInstance) {
|
||||
//List Exports Available for Download
|
||||
|
||||
server.get("/exports", async (req,reply) => {
|
||||
const {data,error} = await server.supabase.from("exports").select().eq("tenant_id",req.user.tenant_id)
|
||||
const data = await server.db
|
||||
.select({
|
||||
id: generatedexports.id,
|
||||
created_at: generatedexports.createdAt,
|
||||
tenant_id: generatedexports.tenantId,
|
||||
start_date: generatedexports.startDate,
|
||||
end_date: generatedexports.endDate,
|
||||
valid_until: generatedexports.validUntil,
|
||||
type: generatedexports.type,
|
||||
url: generatedexports.url,
|
||||
file_path: generatedexports.filePath,
|
||||
})
|
||||
.from(generatedexports)
|
||||
.where(eq(generatedexports.tenantId, req.user.tenant_id))
|
||||
|
||||
console.log(data,error)
|
||||
console.log(data)
|
||||
reply.send(data)
|
||||
|
||||
})
|
||||
@@ -125,4 +140,4 @@ export default async function exportRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { FastifyInstance } from "fastify"
|
||||
import multipart from "@fastify/multipart"
|
||||
import { s3 } from "../utils/s3"
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand
|
||||
GetObjectCommand
|
||||
} from "@aws-sdk/client-s3"
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import archiver from "archiver"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { saveFile } from "../utils/files"
|
||||
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import {
|
||||
@@ -40,39 +40,28 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
const fileBuffer = await data.toBuffer()
|
||||
|
||||
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
|
||||
const { folder = null, type = null, ...otherMeta } = meta
|
||||
|
||||
// 1️⃣ DB-Eintrag erzeugen
|
||||
const inserted = await server.db
|
||||
.insert(files)
|
||||
.values({ tenant: tenantId })
|
||||
.returning()
|
||||
const created = await saveFile(
|
||||
server,
|
||||
tenantId,
|
||||
null,
|
||||
{
|
||||
filename: data.filename,
|
||||
content: fileBuffer,
|
||||
contentType: data.mimetype
|
||||
},
|
||||
folder,
|
||||
type,
|
||||
otherMeta
|
||||
)
|
||||
|
||||
const created = inserted[0]
|
||||
if (!created) throw new Error("Could not create DB entry")
|
||||
|
||||
// 2️⃣ Datei in S3 speichern
|
||||
const fileKey = `${tenantId}/filesbyid/${created.id}/${data.filename}`
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
Body: fileBuffer,
|
||||
ContentType: data.mimetype
|
||||
}))
|
||||
|
||||
// 3️⃣ DB updaten: meta + path
|
||||
await server.db
|
||||
.update(files)
|
||||
.set({
|
||||
...meta,
|
||||
path: fileKey
|
||||
})
|
||||
.where(eq(files.id, created.id))
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
filename: data.filename,
|
||||
path: fileKey
|
||||
filename: created.filename,
|
||||
path: created.key
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -248,7 +237,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
// MULTIPLE PRESIGNED URLs
|
||||
// -------------------------------------------------
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return reply.code(400).send({ error: "No ids provided" })
|
||||
return { files: [] }
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
||||
//import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import dayjs from "dayjs";
|
||||
//import { ready as zplReady } from 'zpl-renderer-js'
|
||||
//import { renderZPL } from "zpl-image";
|
||||
@@ -13,10 +18,12 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import timezone from "dayjs/plugin/timezone.js";
|
||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||
import {citys} from "../../db/schema";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
import {citys, files} from "../../db/schema";
|
||||
import {and, eq, isNull, not} from "drizzle-orm";
|
||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||
import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween)
|
||||
@@ -25,7 +32,40 @@ dayjs.extend(isSameOrBefore)
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
function resolveGitRoot() {
|
||||
const searchRoots = [
|
||||
process.cwd(),
|
||||
path.resolve(process.cwd(), ".."),
|
||||
path.resolve(__dirname, "../../.."),
|
||||
path.resolve(__dirname, "../../../.."),
|
||||
]
|
||||
|
||||
for (const startDir of searchRoots) {
|
||||
let currentDir = startDir
|
||||
|
||||
while (currentDir && currentDir !== path.dirname(currentDir)) {
|
||||
if (existsSync(path.join(currentDir, ".git"))) {
|
||||
return currentDir
|
||||
}
|
||||
|
||||
currentDir = path.dirname(currentDir)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default async function functionRoutes(server: FastifyInstance) {
|
||||
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
server.post("/functions/pdf/:type", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
data: any
|
||||
@@ -100,31 +140,25 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
|
||||
server.get('/functions/check-zip/:zip', async (req, reply) => {
|
||||
const { zip } = req.params as { zip: string }
|
||||
const normalizedZip = String(zip || "").replace(/\D/g, "")
|
||||
|
||||
if (!zip) {
|
||||
return reply.code(400).send({ error: 'ZIP is required' })
|
||||
if (normalizedZip.length !== 5) {
|
||||
return reply.code(400).send({ error: 'ZIP must contain exactly 5 digits' })
|
||||
}
|
||||
|
||||
try {
|
||||
//@ts-ignore
|
||||
const data = await server.db.select().from(citys).where(eq(citys.zip,zip))
|
||||
|
||||
|
||||
/*const { data, error } = await server.supabase
|
||||
.from('citys')
|
||||
const data = await server.db
|
||||
.select()
|
||||
.eq('zip', zip)
|
||||
.maybeSingle()
|
||||
.from(citys)
|
||||
.where(eq(citys.zip, Number(normalizedZip)))
|
||||
|
||||
if (error) {
|
||||
console.log(error)
|
||||
return reply.code(500).send({ error: 'Database error' })
|
||||
}*/
|
||||
|
||||
if (!data) {
|
||||
if (!data.length) {
|
||||
return reply.code(404).send({ error: 'ZIP not found' })
|
||||
}
|
||||
|
||||
const city = data[0]
|
||||
|
||||
//districtMap
|
||||
const bundeslaender = [
|
||||
{ code: 'DE-BW', name: 'Baden-Württemberg' },
|
||||
@@ -148,9 +182,8 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
return reply.send({
|
||||
...data,
|
||||
//@ts-ignore
|
||||
state_code: bundeslaender.find(i => i.name === data.countryName)
|
||||
...city,
|
||||
state_code: bundeslaender.find(i => i.name === city.countryName)?.code || null
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
@@ -158,6 +191,55 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
server.get('/functions/changelog', async (req, reply) => {
|
||||
const { limit } = req.query as { limit?: string | number }
|
||||
const parsedLimit = Number(limit)
|
||||
const safeLimit = Number.isFinite(parsedLimit)
|
||||
? Math.min(Math.max(parsedLimit, 1), 50)
|
||||
: 15
|
||||
|
||||
const gitRoot = resolveGitRoot()
|
||||
|
||||
if (!gitRoot) {
|
||||
return reply.code(500).send({ error: 'Git repository not found' })
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', [
|
||||
'-C',
|
||||
gitRoot,
|
||||
'log',
|
||||
`--max-count=${safeLimit}`,
|
||||
'--date=iso-strict',
|
||||
'--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e'
|
||||
])
|
||||
|
||||
const entries = stdout
|
||||
.split('\x1e')
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map(entry => {
|
||||
const [hash, shortHash, subject, authorName, committedAt] = entry.split('\x1f')
|
||||
|
||||
return {
|
||||
hash,
|
||||
shortHash,
|
||||
subject,
|
||||
authorName,
|
||||
committedAt
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
repositoryRoot: gitRoot,
|
||||
entries
|
||||
})
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: 'Failed to load changelog' })
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/functions/serial/start', async (req, reply) => {
|
||||
console.log(req.body)
|
||||
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
|
||||
@@ -179,44 +261,77 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
||||
})
|
||||
|
||||
server.post('/functions/services/backfillfiletext', async (req, reply) => {
|
||||
const tenantId = req.user.tenant_id
|
||||
|
||||
/*server.post('/print/zpl/preview', async (req, reply) => {
|
||||
const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}
|
||||
const pendingFiles = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
and(
|
||||
eq(files.tenant, tenantId),
|
||||
eq(files.archived, false),
|
||||
not(isNull(files.path)),
|
||||
isNull(files.extractedText)
|
||||
)
|
||||
)
|
||||
|
||||
console.log(widthMm,heightMm,dpmm)
|
||||
let processed = 0
|
||||
let withText = 0
|
||||
let errors = 0
|
||||
|
||||
if (!zpl) {
|
||||
return reply.code(400).send({ error: 'Missing ZPL string' })
|
||||
for (const file of pendingFiles) {
|
||||
try {
|
||||
const response: any = await s3.send(new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: file.path!
|
||||
}))
|
||||
|
||||
const fileBuffer = await streamToBuffer(response.Body)
|
||||
const result = await storeExtractedTextForFile(
|
||||
server,
|
||||
file.id,
|
||||
fileBuffer,
|
||||
file.mimeType,
|
||||
file.name || file.path?.split("/").pop()
|
||||
)
|
||||
|
||||
processed += 1
|
||||
if (result.text) withText += 1
|
||||
} catch (err) {
|
||||
errors += 1
|
||||
server.log.error(`Failed to backfill extracted text for file ${file.id}`)
|
||||
server.log.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 1️⃣ Renderer initialisieren
|
||||
const { api } = await zplReady
|
||||
|
||||
// 2️⃣ Rendern (liefert base64-encoded PNG)
|
||||
const base64Png = await api.zplToBase64Async(zpl, widthMm, heightMm, dpmm)
|
||||
|
||||
return await encodeBase64ToNiimbot(base64Png, 'top')
|
||||
} catch (err) {
|
||||
console.error('[ZPL Preview Error]', err)
|
||||
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
|
||||
return {
|
||||
pending: pendingFiles.length,
|
||||
processed,
|
||||
withText,
|
||||
errors
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/functions/services/syncdokubox', async (req, reply) => {
|
||||
|
||||
await server.services.dokuboxSync.run()
|
||||
})
|
||||
|
||||
server.post('/print/label', async (req, reply) => {
|
||||
const { context, width=584, heigth=354 } = req.body as {context:any,width:number,heigth:number}
|
||||
const { context, width = 584, height = 354 } = req.body as {context:any,width:number,height:number}
|
||||
|
||||
try {
|
||||
const base64 = await generateLabel(context,width,heigth)
|
||||
const base64 = await generateLabel(context,width,height)
|
||||
|
||||
return {
|
||||
encoded: await encodeBase64ToNiimbot(base64, 'top'),
|
||||
base64: base64
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ZPL Preview Error]', err)
|
||||
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
|
||||
console.error('[Label Render Error]', err)
|
||||
return reply.code(500).send({ error: err.message || 'Failed to render label' })
|
||||
}
|
||||
})*/
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ import { FastifyInstance } from "fastify";
|
||||
export default async function routes(server: FastifyInstance) {
|
||||
server.get("/ping", async () => {
|
||||
// Testquery gegen DB
|
||||
const { data, error } = await server.supabase.from("tenants").select("id").limit(1);
|
||||
const result = await server.db.execute("SELECT NOW()");
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
db: error ? "not connected" : "connected",
|
||||
tenant_count: data?.length ?? 0
|
||||
db: JSON.stringify(result.rows[0]),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { FastifyPluginAsync } from 'fastify'
|
||||
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
import { findCustomerOrContactByEmailOrDomain } from "../utils/helpers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { helpdesk_conversations, helpdesk_messages } from "../../db/schema";
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 📧 Interne M2M-Route für eingehende E-Mails
|
||||
@@ -52,12 +53,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
|
||||
// 3️⃣ Konversation anhand In-Reply-To suchen
|
||||
let conversationId: string | null = null
|
||||
if (in_reply_to) {
|
||||
const { data: msg } = await server.supabase
|
||||
.from('helpdesk_messages')
|
||||
.select('conversation_id')
|
||||
.eq('external_message_id', in_reply_to)
|
||||
.maybeSingle()
|
||||
conversationId = msg?.conversation_id || null
|
||||
const msg = await server.db
|
||||
.select({ conversationId: helpdesk_messages.conversationId })
|
||||
.from(helpdesk_messages)
|
||||
.where(eq(helpdesk_messages.externalMessageId, in_reply_to))
|
||||
.limit(1)
|
||||
conversationId = msg[0]?.conversationId || null
|
||||
}
|
||||
|
||||
// 4️⃣ Neue Konversation anlegen falls keine existiert
|
||||
@@ -73,12 +74,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
|
||||
})
|
||||
conversationId = conversation.id
|
||||
} else {
|
||||
const { data } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.select('*')
|
||||
.eq('id', conversationId)
|
||||
.single()
|
||||
conversation = data
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(helpdesk_conversations)
|
||||
.where(eq(helpdesk_conversations.id, conversationId))
|
||||
.limit(1)
|
||||
conversation = rows[0]
|
||||
}
|
||||
|
||||
// 5️⃣ Nachricht speichern
|
||||
@@ -96,7 +97,7 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
|
||||
return res.status(201).send({
|
||||
success: true,
|
||||
conversation_id: conversationId,
|
||||
ticket_number: conversation.ticket_number,
|
||||
ticket_number: conversation?.ticket_number || conversation?.ticketNumber,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,70 +3,9 @@ import { FastifyPluginAsync } from 'fastify'
|
||||
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
|
||||
/**
|
||||
* Öffentliche Route zum Empfang eingehender Kontaktformular-Nachrichten.
|
||||
* Authentifizierung: über `public_token` aus helpdesk_channel_instances
|
||||
*/
|
||||
|
||||
function extractDomain(email) {
|
||||
if (!email) return null
|
||||
const parts = email.split("@")
|
||||
return parts.length === 2 ? parts[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
async function findCustomerOrContactByEmailOrDomain(server,fromMail, tenantId) {
|
||||
const sender = fromMail
|
||||
const senderDomain = extractDomain(sender)
|
||||
if (!senderDomain) return null
|
||||
|
||||
|
||||
// 1️⃣ Direkter Match über contacts
|
||||
const { data: contactMatch } = await server.supabase
|
||||
.from("contacts")
|
||||
.select("id, customer")
|
||||
.eq("email", sender)
|
||||
.eq("tenant", tenantId)
|
||||
.maybeSingle()
|
||||
|
||||
if (contactMatch?.customer_id) return {
|
||||
customer: contactMatch.customer,
|
||||
contact: contactMatch.id
|
||||
}
|
||||
|
||||
// 2️⃣ Kunden laden, bei denen E-Mail oder Rechnungsmail passt
|
||||
const { data: customers, error } = await server.supabase
|
||||
.from("customers")
|
||||
.select("id, infoData")
|
||||
.eq("tenant", tenantId)
|
||||
|
||||
if (error) {
|
||||
console.error(`[Helpdesk] Fehler beim Laden der Kunden:`, error.message)
|
||||
return null
|
||||
}
|
||||
|
||||
// 3️⃣ Durch Kunden iterieren und prüfen
|
||||
for (const c of customers || []) {
|
||||
const info = c.infoData || {}
|
||||
const email = info.email?.toLowerCase()
|
||||
const invoiceEmail = info.invoiceEmail?.toLowerCase()
|
||||
|
||||
const emailDomain = extractDomain(email)
|
||||
const invoiceDomain = extractDomain(invoiceEmail)
|
||||
|
||||
// exakter Match oder Domain-Match
|
||||
if (
|
||||
sender === email ||
|
||||
sender === invoiceEmail ||
|
||||
senderDomain === emailDomain ||
|
||||
senderDomain === invoiceDomain
|
||||
) {
|
||||
return {customer: c.id, contact:null}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
import { findCustomerOrContactByEmailOrDomain } from "../utils/helpers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { helpdesk_channel_instances } from "../../db/schema";
|
||||
|
||||
const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
|
||||
// Öffentliche POST-Route
|
||||
@@ -85,17 +24,18 @@ const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
|
||||
}
|
||||
|
||||
// 1️⃣ Kanalinstanz anhand des Tokens ermitteln
|
||||
const { data: channel, error: channelError } = await server.supabase
|
||||
.from('helpdesk_channel_instances')
|
||||
.select('*')
|
||||
.eq('public_token', public_token)
|
||||
.single()
|
||||
const channels = await server.db
|
||||
.select()
|
||||
.from(helpdesk_channel_instances)
|
||||
.where(eq(helpdesk_channel_instances.publicToken, public_token))
|
||||
.limit(1)
|
||||
const channel = channels[0]
|
||||
|
||||
if (channelError || !channel) {
|
||||
if (!channel) {
|
||||
return res.status(404).send({ error: 'Invalid channel token' })
|
||||
}
|
||||
|
||||
const tenant_id = channel.tenant_id
|
||||
const tenant_id = channel.tenantId
|
||||
const channel_instance_id = channel.id
|
||||
|
||||
// @ts-ignore
|
||||
|
||||
@@ -5,6 +5,13 @@ import { addMessage, getMessages } from '../modules/helpdesk/helpdesk.message.se
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
import {decrypt, encrypt} from "../utils/crypt";
|
||||
import nodemailer from "nodemailer"
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
helpdesk_channel_instances,
|
||||
helpdesk_contacts,
|
||||
helpdesk_conversations,
|
||||
helpdesk_messages,
|
||||
} from "../../db/schema";
|
||||
|
||||
const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||
// 📩 1. Liste aller Konversationen
|
||||
@@ -58,15 +65,30 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
const {id: conversation_id} = req.params as {id: string}
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.select('*, helpdesk_contacts(*)')
|
||||
.eq('tenant_id', tenant_id)
|
||||
.eq('id', conversation_id)
|
||||
.single()
|
||||
const rows = await server.db
|
||||
.select({
|
||||
conversation: helpdesk_conversations,
|
||||
contact: helpdesk_contacts
|
||||
})
|
||||
.from(helpdesk_conversations)
|
||||
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
|
||||
.where(eq(helpdesk_conversations.id, conversation_id))
|
||||
|
||||
if (error) return res.status(404).send({ error: 'Conversation not found' })
|
||||
return res.send(data)
|
||||
const data = rows[0]
|
||||
if (!data || data.conversation.tenantId !== tenant_id) return res.status(404).send({ error: 'Conversation not found' })
|
||||
|
||||
return res.send({
|
||||
...data.conversation,
|
||||
channel_instance_id: data.conversation.channelInstanceId,
|
||||
contact_id: data.conversation.contactId,
|
||||
contact_person_id: data.conversation.contactPersonId,
|
||||
created_at: data.conversation.createdAt,
|
||||
customer_id: data.conversation.customerId,
|
||||
last_message_at: data.conversation.lastMessageAt,
|
||||
tenant_id: data.conversation.tenantId,
|
||||
ticket_number: data.conversation.ticketNumber,
|
||||
helpdesk_contacts: data.contact,
|
||||
})
|
||||
})
|
||||
|
||||
// 🔄 4. Konversation Status ändern
|
||||
@@ -181,36 +203,39 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
|
||||
}
|
||||
|
||||
// Speichern in Supabase
|
||||
const { data, error } = await server.supabase
|
||||
.from("helpdesk_channel_instances")
|
||||
.insert({
|
||||
tenant_id,
|
||||
type_id,
|
||||
const inserted = await server.db
|
||||
.insert(helpdesk_channel_instances)
|
||||
.values({
|
||||
tenantId: tenant_id,
|
||||
typeId: type_id,
|
||||
name,
|
||||
config: safeConfig,
|
||||
is_active,
|
||||
isActive: is_active,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
.returning()
|
||||
|
||||
if (error) throw error
|
||||
const data = inserted[0]
|
||||
if (!data) throw new Error("Konnte Channel nicht erstellen")
|
||||
const responseConfig: any = data.config
|
||||
|
||||
// sensible Felder aus Response entfernen
|
||||
if (data.config?.imap) {
|
||||
delete data.config.imap.host
|
||||
delete data.config.imap.user
|
||||
delete data.config.imap.pass
|
||||
if (responseConfig?.imap) {
|
||||
delete responseConfig.imap.host
|
||||
delete responseConfig.imap.user
|
||||
delete responseConfig.imap.pass
|
||||
}
|
||||
if (data.config?.smtp) {
|
||||
delete data.config.smtp.host
|
||||
delete data.config.smtp.user
|
||||
delete data.config.smtp.pass
|
||||
if (responseConfig?.smtp) {
|
||||
delete responseConfig.smtp.host
|
||||
delete responseConfig.smtp.user
|
||||
delete responseConfig.smtp.pass
|
||||
}
|
||||
|
||||
reply.send({
|
||||
message: "E-Mail-Channel erfolgreich erstellt",
|
||||
channel: data,
|
||||
channel: {
|
||||
...data,
|
||||
config: responseConfig
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Fehler bei Channel-Erstellung:", err)
|
||||
@@ -234,29 +259,29 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||
const { text } = req.body as { text: string }
|
||||
|
||||
// 🔹 Konversation inkl. Channel + Kontakt laden
|
||||
const { data: conv, error: convErr } = await server.supabase
|
||||
.from("helpdesk_conversations")
|
||||
.select(`
|
||||
id,
|
||||
tenant_id,
|
||||
subject,
|
||||
channel_instance_id,
|
||||
helpdesk_contacts(email),
|
||||
helpdesk_channel_instances(config, name),
|
||||
ticket_number
|
||||
`)
|
||||
.eq("id", conversationId)
|
||||
.single()
|
||||
const rows = await server.db
|
||||
.select({
|
||||
conversation: helpdesk_conversations,
|
||||
contact: helpdesk_contacts,
|
||||
channel: helpdesk_channel_instances,
|
||||
})
|
||||
.from(helpdesk_conversations)
|
||||
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
|
||||
.leftJoin(helpdesk_channel_instances, eq(helpdesk_channel_instances.id, helpdesk_conversations.channelInstanceId))
|
||||
.where(eq(helpdesk_conversations.id, conversationId))
|
||||
.limit(1)
|
||||
|
||||
const conv = rows[0]
|
||||
|
||||
console.log(conv)
|
||||
|
||||
if (convErr || !conv) {
|
||||
if (!conv) {
|
||||
reply.status(404).send({ error: "Konversation nicht gefunden" })
|
||||
return
|
||||
}
|
||||
|
||||
const contact = conv.helpdesk_contacts as unknown as {email: string}
|
||||
const channel = conv.helpdesk_channel_instances as unknown as {name: string}
|
||||
const contact = conv.contact as unknown as {email: string}
|
||||
const channel = conv.channel as unknown as {name: string, config: any}
|
||||
|
||||
console.log(contact)
|
||||
if (!contact?.email) {
|
||||
@@ -288,7 +313,7 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||
const mailOptions = {
|
||||
from: `"${channel?.name}" <${user}>`,
|
||||
to: contact.email,
|
||||
subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`,
|
||||
subject: `${conv.conversation.ticketNumber} | ${conv.conversation.subject}` || `${conv.conversation.ticketNumber} | Antwort vom FEDEO Helpdesk`,
|
||||
text,
|
||||
}
|
||||
|
||||
@@ -296,24 +321,22 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
|
||||
|
||||
// 💾 Nachricht speichern
|
||||
const { error: insertErr } = await server.supabase
|
||||
.from("helpdesk_messages")
|
||||
.insert({
|
||||
tenant_id: conv.tenant_id,
|
||||
conversation_id: conversationId,
|
||||
await server.db
|
||||
.insert(helpdesk_messages)
|
||||
.values({
|
||||
tenantId: conv.conversation.tenantId,
|
||||
conversationId: conversationId,
|
||||
direction: "outgoing",
|
||||
payload: { type: "text", text },
|
||||
external_message_id: info.messageId,
|
||||
received_at: new Date().toISOString(),
|
||||
externalMessageId: info.messageId,
|
||||
receivedAt: new Date(),
|
||||
})
|
||||
|
||||
if (insertErr) throw insertErr
|
||||
|
||||
// 🔁 Konversation aktualisieren
|
||||
await server.supabase
|
||||
.from("helpdesk_conversations")
|
||||
.update({ last_message_at: new Date().toISOString() })
|
||||
.eq("id", conversationId)
|
||||
await server.db
|
||||
.update(helpdesk_conversations)
|
||||
.set({ lastMessageAt: new Date() })
|
||||
.where(eq(helpdesk_conversations.id, conversationId))
|
||||
|
||||
reply.send({
|
||||
message: "E-Mail erfolgreich gesendet",
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
// src/routes/resources/history.ts
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { authProfiles, historyitems } from "../../db/schema";
|
||||
|
||||
const columnMap: Record<string, string> = {
|
||||
const columnMap: Record<string, any> = {
|
||||
customers: historyitems.customer,
|
||||
members: historyitems.customer,
|
||||
vendors: historyitems.vendor,
|
||||
projects: historyitems.project,
|
||||
plants: historyitems.plant,
|
||||
contacts: historyitems.contact,
|
||||
tasks: historyitems.task,
|
||||
vehicles: historyitems.vehicle,
|
||||
events: historyitems.event,
|
||||
files: historyitems.file,
|
||||
products: historyitems.product,
|
||||
inventoryitems: historyitems.inventoryitem,
|
||||
inventoryitemgroups: historyitems.inventoryitemgroup,
|
||||
checks: historyitems.check,
|
||||
costcentres: historyitems.costcentre,
|
||||
ownaccounts: historyitems.ownaccount,
|
||||
documentboxes: historyitems.documentbox,
|
||||
hourrates: historyitems.hourrate,
|
||||
services: historyitems.service,
|
||||
customerspaces: historyitems.customerspace,
|
||||
customerinventoryitems: historyitems.customerinventoryitem,
|
||||
memberrelations: historyitems.memberrelation,
|
||||
};
|
||||
|
||||
const insertFieldMap: Record<string, string> = {
|
||||
customers: "customer",
|
||||
members: "customer",
|
||||
vendors: "vendor",
|
||||
projects: "project",
|
||||
plants: "plant",
|
||||
contracts: "contract",
|
||||
contacts: "contact",
|
||||
tasks: "task",
|
||||
vehicles: "vehicle",
|
||||
@@ -15,17 +42,61 @@ const columnMap: Record<string, string> = {
|
||||
products: "product",
|
||||
inventoryitems: "inventoryitem",
|
||||
inventoryitemgroups: "inventoryitemgroup",
|
||||
absencerequests: "absencerequest",
|
||||
checks: "check",
|
||||
costcentres: "costcentre",
|
||||
ownaccounts: "ownaccount",
|
||||
documentboxes: "documentbox",
|
||||
hourrates: "hourrate",
|
||||
services: "service",
|
||||
roles: "role",
|
||||
};
|
||||
customerspaces: "customerspace",
|
||||
customerinventoryitems: "customerinventoryitem",
|
||||
memberrelations: "memberrelation",
|
||||
}
|
||||
|
||||
const parseId = (value: string) => {
|
||||
if (/^\d+$/.test(value)) return Number(value)
|
||||
return value
|
||||
}
|
||||
|
||||
export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
server.get("/history", {
|
||||
schema: {
|
||||
tags: ["History"],
|
||||
summary: "Get all history entries for the active tenant",
|
||||
},
|
||||
}, async (req: any) => {
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(historyitems)
|
||||
.where(eq(historyitems.tenant, req.user?.tenant_id))
|
||||
.orderBy(asc(historyitems.createdAt));
|
||||
|
||||
const userIds = Array.from(
|
||||
new Set(data.map((item) => item.createdBy).filter(Boolean))
|
||||
) as string[];
|
||||
|
||||
const profiles = userIds.length > 0
|
||||
? await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, req.user?.tenant_id),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
: [];
|
||||
|
||||
const profileByUserId = new Map(
|
||||
profiles.map((profile) => [profile.user_id, profile])
|
||||
);
|
||||
|
||||
return data.map((historyitem) => ({
|
||||
...historyitem,
|
||||
created_at: historyitem.createdAt,
|
||||
created_by: historyitem.createdBy,
|
||||
created_by_profile: historyitem.createdBy ? profileByUserId.get(historyitem.createdBy) || null : null,
|
||||
}));
|
||||
});
|
||||
|
||||
server.get<{
|
||||
Params: { resource: string; id: string }
|
||||
}>("/resource/:resource/:id/history", {
|
||||
@@ -49,29 +120,36 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
return reply.code(400).send({ error: `History not supported for resource '${resource}'` });
|
||||
}
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from("historyitems")
|
||||
.select("*")
|
||||
.eq(column, id)
|
||||
.order("created_at", { ascending: true });
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(historyitems)
|
||||
.where(eq(column, parseId(id)))
|
||||
.orderBy(asc(historyitems.createdAt));
|
||||
|
||||
if (error) {
|
||||
server.log.error(error);
|
||||
return reply.code(500).send({ error: "Failed to fetch history" });
|
||||
}
|
||||
const userIds = Array.from(
|
||||
new Set(data.map((item) => item.createdBy).filter(Boolean))
|
||||
) as string[]
|
||||
|
||||
const {data:users, error:usersError} = await server.supabase
|
||||
.from("auth_users")
|
||||
.select("*, auth_profiles(*), tenants!auth_tenant_users(*)")
|
||||
const profiles = userIds.length > 0
|
||||
? await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, req.user?.tenant_id),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
: []
|
||||
|
||||
const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id))
|
||||
const profileByUserId = new Map(
|
||||
profiles.map((profile) => [profile.user_id, profile])
|
||||
)
|
||||
|
||||
const dataCombined = data.map(historyitem => {
|
||||
return {
|
||||
...historyitem,
|
||||
created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by) ? filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] : null
|
||||
}
|
||||
})
|
||||
const dataCombined = data.map((historyitem) => ({
|
||||
...historyitem,
|
||||
created_at: historyitem.createdAt,
|
||||
created_by: historyitem.createdBy,
|
||||
created_by_profile: historyitem.createdBy ? profileByUserId.get(historyitem.createdBy) || null : null,
|
||||
}))
|
||||
|
||||
|
||||
|
||||
@@ -128,29 +206,33 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
const userId = (req.user as any)?.user_id;
|
||||
|
||||
|
||||
const fkField = columnMap[resource];
|
||||
const fkField = insertFieldMap[resource];
|
||||
if (!fkField) {
|
||||
return reply.code(400).send({ error: `Unknown resource: ${resource}` });
|
||||
}
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from("historyitems")
|
||||
.insert({
|
||||
const inserted = await server.db
|
||||
.insert(historyitems)
|
||||
.values({
|
||||
text,
|
||||
[fkField]: id,
|
||||
[fkField]: parseId(id),
|
||||
oldVal: old_val || null,
|
||||
newVal: new_val || null,
|
||||
config: config || null,
|
||||
tenant: (req.user as any)?.tenant_id,
|
||||
created_by: userId
|
||||
createdBy: userId
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
.returning()
|
||||
|
||||
if (error) {
|
||||
return reply.code(500).send({ error: error.message });
|
||||
const data = inserted[0]
|
||||
if (!data) {
|
||||
return reply.code(500).send({ error: "Failed to create history entry" });
|
||||
}
|
||||
|
||||
return reply.code(201).send(data);
|
||||
return reply.code(201).send({
|
||||
...data,
|
||||
created_at: data.createdAt,
|
||||
created_by: data.createdBy
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
63
backend/src/routes/internal/auth.m2m.ts
Normal file
63
backend/src/routes/internal/auth.m2m.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { authTenantUsers } from "../../../db/schema"
|
||||
import { secrets } from "../../utils/secrets"
|
||||
|
||||
export default async function authM2mInternalRoutes(server: FastifyInstance) {
|
||||
server.post("/auth/m2m/token", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Exchange M2M API key for a short-lived JWT",
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
expires_in_seconds: { type: "number" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.user_id || !req.user?.tenant_id || !req.user?.email) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const membership = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(and(
|
||||
eq(authTenantUsers.user_id, req.user.user_id),
|
||||
eq(authTenantUsers.tenant_id, Number(req.user.tenant_id))
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!membership[0]) {
|
||||
return reply.code(403).send({ error: "User is not assigned to tenant" })
|
||||
}
|
||||
|
||||
const requestedTtl = Number((req.body as any)?.expires_in_seconds ?? 900)
|
||||
const ttlSeconds = Math.min(3600, Math.max(60, requestedTtl))
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: req.user.user_id,
|
||||
email: req.user.email,
|
||||
tenant_id: req.user.tenant_id,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: ttlSeconds }
|
||||
)
|
||||
|
||||
return {
|
||||
token_type: "Bearer",
|
||||
access_token: token,
|
||||
expires_in_seconds: ttlSeconds,
|
||||
user_id: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("POST /internal/auth/m2m/token ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
// routes/notifications.routes.ts
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { NotificationService, UserDirectory } from '../modules/notification.service';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authUsers } from "../../db/schema";
|
||||
|
||||
// Beispiel: E-Mail aus eigener User-Tabelle laden
|
||||
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
|
||||
const { data, error } = await server.supabase
|
||||
.from('auth_users')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
if (error || !data) return null;
|
||||
const rows = await server.db
|
||||
.select({ email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
const data = rows[0]
|
||||
if (!data) return null;
|
||||
return { email: data.email };
|
||||
};
|
||||
|
||||
export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
// wichtig: server.supabase ist über app verfügbar
|
||||
|
||||
const svc = new NotificationService(server, getUserDirectory);
|
||||
|
||||
server.post('/notifications/trigger', async (req, reply) => {
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||
|
||||
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
|
||||
|
||||
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
||||
server.get("/workflows/context/:token", async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
|
||||
// Wir lesen die PIN aus dem Header (Best Practice für Security)
|
||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||
|
||||
try {
|
||||
const context = await publicLinkService.getLinkContext(server, token, pin);
|
||||
|
||||
return reply.send(context);
|
||||
|
||||
} catch (error: any) {
|
||||
// Spezifische Fehlercodes für das Frontend
|
||||
if (error.message === "Link_NotFound") {
|
||||
return reply.code(404).send({ error: "Link nicht gefunden oder abgelaufen" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Required") {
|
||||
return reply.code(401).send({
|
||||
error: "PIN erforderlich",
|
||||
code: "PIN_REQUIRED",
|
||||
requirePin: true
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Invalid") {
|
||||
return reply.code(403).send({
|
||||
error: "PIN falsch",
|
||||
code: "PIN_INVALID",
|
||||
requirePin: true
|
||||
});
|
||||
}
|
||||
if (error.message === "Link_NotFound") return reply.code(404).send({ error: "Link nicht gefunden" });
|
||||
if (error.message === "Pin_Required") return reply.code(401).send({ error: "PIN erforderlich", requirePin: true });
|
||||
if (error.message === "Pin_Invalid") return reply.code(403).send({ error: "PIN falsch", requirePin: true });
|
||||
|
||||
server.log.error(error);
|
||||
return reply.code(500).send({ error: "Interner Server Fehler" });
|
||||
@@ -43,49 +22,31 @@ export default async function publiclinksNonAuthenticatedRoutes(server: FastifyI
|
||||
|
||||
server.post("/workflows/submit/:token", async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
// PIN sicher aus dem Header lesen
|
||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||
// Der Body enthält { profile, project, service, ... }
|
||||
const payload = req.body;
|
||||
|
||||
console.log(payload)
|
||||
const body = req.body as any;
|
||||
|
||||
try {
|
||||
// Service aufrufen (führt die 3 Schritte aus: Lieferschein -> Zeit -> History)
|
||||
const result = await publicLinkService.submitFormData(server, token, payload, pin);
|
||||
const quantity = parseFloat(body.quantity) || 0;
|
||||
|
||||
// 201 Created zurückgeben
|
||||
// Wir nutzen das vom User gewählte deliveryDate
|
||||
// Falls kein Datum geschickt wurde, Fallback auf Heute
|
||||
const baseDate = body.deliveryDate ? dayjs(body.deliveryDate) : dayjs();
|
||||
|
||||
const payload = {
|
||||
...body,
|
||||
// Wir mappen das deliveryDate auf die Zeitstempel
|
||||
// Start ist z.B. 08:00 Uhr am gewählten Tag, Ende ist Start + Menge
|
||||
startDate: baseDate.hour(8).minute(0).toDate(),
|
||||
endDate: baseDate.hour(8).add(quantity, 'hour').toDate(),
|
||||
deliveryDate: baseDate.format('YYYY-MM-DD')
|
||||
};
|
||||
|
||||
const result = await publicLinkService.submitFormData(server, token, payload, pin);
|
||||
return reply.code(201).send(result);
|
||||
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
|
||||
// Fehler-Mapping für saubere HTTP Codes
|
||||
if (error.message === "Link_NotFound") {
|
||||
return reply.code(404).send({ error: "Link ungültig oder nicht aktiv" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Required") {
|
||||
return reply.code(401).send({ error: "PIN erforderlich" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Invalid") {
|
||||
return reply.code(403).send({ error: "PIN ist falsch" });
|
||||
}
|
||||
|
||||
if (error.message === "Profile_Missing") {
|
||||
return reply.code(400).send({ error: "Kein Mitarbeiter-Profil gefunden (weder im Link noch in der Eingabe)" });
|
||||
}
|
||||
|
||||
if (error.message === "Project not found" || error.message === "Service not found") {
|
||||
return reply.code(400).send({ error: "Ausgewähltes Projekt oder Leistung existiert nicht mehr." });
|
||||
}
|
||||
|
||||
// Fallback für alle anderen Fehler (z.B. DB Constraints)
|
||||
return reply.code(500).send({
|
||||
error: "Interner Fehler beim Speichern",
|
||||
details: error.message
|
||||
});
|
||||
server.log.error(error);
|
||||
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
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