8 Commits

Author SHA1 Message Date
e7554fa2cc .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:42:28 +00:00
7c1fabf58a .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:40:58 +00:00
1203b6cbd1 .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 11:39:03 +00:00
525f2906fb .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2026-01-15 11:38:50 +00:00
b105382abf .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 11:38:00 +00:00
b1cdec7d17 Merge pull request 'Added feature request template' (#62) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #62
2026-01-15 11:31:57 +00:00
f1d512b2e5 Merge pull request 'dev' (#61) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #61
2026-01-15 11:29:15 +00:00
db21b43120 Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #40
2026-01-08 22:21:06 +00:00
338 changed files with 7703 additions and 96605 deletions

View File

@@ -2,37 +2,18 @@
name: 🐛 Bug Report
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
title: '[BUG] '
labels: bug
labels: Problem
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.

View File

@@ -2,19 +2,16 @@
name: ✨ Feature Request
about: Schlage eine Idee für dieses Projekt vor.
title: '[FEATURE] '
labels: enhancement
labels: Funktionswunsch
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.

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# 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
View File

@@ -1,12 +0,0 @@
<?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
View File

@@ -1,8 +0,0 @@
<?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
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

532
README.md
View File

@@ -1,439 +1,109 @@
# 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:
# Docker Compose Setup
- `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
## ENV Vars
Die Konfiguration erfolgt uber Umgebungsvariablen beziehungsweise eine `.env`-Datei im Deploy-Verzeichnis.
- DOMAIN
- PDF_LICENSE
- DB_PASS
- DB_USER
- CONTACT_EMAIL
## 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
## Docker Compose File
~~~
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
frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:main
restart: always
environment:
- NUXT_PUBLIC_API_BASE=https://${DOMAIN}/backend
- NUXT_PUBLIC_PDF_LICENSE=${PDF_LICENSE}
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3000"
# Middlewares
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
# Web Entrypoint
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
- "traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
backend:
image: git.federspiel.tech/flfeders/fedeo/backend:main
restart: always
environment:
- INFISICAL_CLIENT_ID=
- INFISICAL_CLIENT_SECRET=
- NODE_ENV=production
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3100"
# Middlewares
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
# Web Entrypoint
- "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
- "traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
# db:
# image: postgres
# restart: always
# shm_size: 128mb
# environment:
# POSTGRES_PASSWORD:
# POSTGRES_USER:
# POSTGRES_DB:
# volumes:
# - ./pg-data:/var/lib/postgresql/data
# ports:
# - "5432:5432"
traefik:
image: traefik:v2.11
restart: unless-stopped
container_name: traefik
command:
- "--api.insecure=false"
- "--api.dashboard=false"
- "--api.debug=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=traefik"
- "--entrypoints.web.address=:80"
- "--entrypoints.web-secured.address=:443"
- "--accesslog=true"
- "--accesslog.filepath=/logs/access.log"
- "--accesslog.bufferingsize=5000"
- "--accesslog.fields.defaultMode=keep"
- "--accesslog.fields.headers.defaultMode=keep"
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
- "--certificatesresolvers.mytlschallenge.acme.email=${CONTACT_EMAIL}"
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
ports:
- 80:80
- 443:443
volumes:
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik/logs:/logs"
networks:
- traefik
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.
traefik:
external: false
~~~

1
backend/.gitignore vendored
View File

@@ -3,4 +3,3 @@ node_modules
.env
/src/generated/prisma
/dist/

View File

@@ -1,3 +0,0 @@
{
"rules": []
}

View File

@@ -1,14 +1,6 @@
FROM node:20-bookworm-slim
FROM node:20-alpine
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 ./

View File

@@ -1,27 +1,13 @@
// src/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import {secrets} from "../src/utils/secrets";
import * as schema from "./schema"
console.log("[DB INIT] 1. Suche Connection String...");
// 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,
});
connectionString: secrets.DATABASE_URL,
max: 10, // je nach Last
})
// 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 });
export const db = drizzle(pool , {schema})

View File

@@ -1,2 +0,0 @@
-- No-op migration: Datei war im Journal referenziert, aber fehlte im Repository.
SELECT 1;

View File

@@ -1,2 +0,0 @@
-- No-op migration: Datei war im Journal referenziert, aber fehlte im Repository.
SELECT 1;

View File

@@ -1,123 +0,0 @@
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;

View File

@@ -1 +0,0 @@
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,3 +0,0 @@
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;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "contracttypes" ADD COLUMN "billingInterval" text;
--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,73 +0,0 @@
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)
);

View File

@@ -1,3 +0,0 @@
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;

View File

@@ -1,20 +0,0 @@
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;

View File

@@ -1,4 +0,0 @@
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;

View File

@@ -1,33 +0,0 @@
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';

View File

@@ -1,108 +0,0 @@
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;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "accounts" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "createddocuments"
ALTER COLUMN "customSurchargePercentage" TYPE double precision
USING "customSurchargePercentage"::double precision;

View File

@@ -1 +0,0 @@
ALTER TABLE "files" ADD COLUMN "extracted_text" text;

View File

@@ -1,2 +0,0 @@
ALTER TABLE "auth_users"
ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;

View File

@@ -1,2 +0,0 @@
ALTER TABLE "tasks"
ADD COLUMN "dependency_ids" jsonb NOT NULL DEFAULT '[]'::jsonb;

View File

@@ -1,2 +0,0 @@
ALTER TABLE "tenants"
ADD COLUMN "taxEvaluationPeriod" text DEFAULT 'monthly' NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,132 +36,6 @@
"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
}
]
}

View File

@@ -16,7 +16,6 @@ export const accounts = pgTable("accounts", {
number: text("number").notNull(),
label: text("label").notNull(),
accountChart: text("accountChart").notNull().default("skr03"),
description: text("description"),
})

View File

@@ -12,7 +12,6 @@ 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 }),

View File

@@ -11,7 +11,6 @@ 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(
@@ -49,9 +48,6 @@ 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"),
@@ -61,7 +57,6 @@ export const contracts = pgTable(
sepaDate: timestamp("sepaDate", { withTimezone: true }),
paymentType: text("paymentType"),
billingInterval: text("billingInterval"),
invoiceDispatch: text("invoiceDispatch"),
ownFields: jsonb("ownFields").notNull().default({}),

View File

@@ -1,40 +0,0 @@
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

View File

@@ -6,7 +6,6 @@ import {
jsonb,
boolean,
smallint,
doublePrecision,
uuid,
} from "drizzle-orm/pg-core"
@@ -97,7 +96,7 @@ export const createddocuments = pgTable("createddocuments", {
taxType: text("taxType"),
customSurchargePercentage: doublePrecision("customSurchargePercentage")
customSurchargePercentage: smallint("customSurchargePercentage")
.notNull()
.default(0),

View File

@@ -1,66 +0,0 @@
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

View File

@@ -10,7 +10,6 @@ import {
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { memberrelations } from "./memberrelations"
export const customers = pgTable(
"customers",
@@ -63,8 +62,6 @@ 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),
}
)

View File

@@ -1,54 +0,0 @@
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

View File

@@ -3,7 +3,7 @@ import {
uuid,
timestamp,
text,
bigint, jsonb,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
@@ -23,11 +23,6 @@ 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

View File

@@ -1,39 +0,0 @@
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

View File

@@ -66,7 +66,6 @@ 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),
@@ -74,7 +73,6 @@ 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

View File

@@ -20,8 +20,6 @@ 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"
@@ -34,7 +32,6 @@ 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" })
@@ -102,12 +99,6 @@ 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(

View File

@@ -14,7 +14,7 @@ export const hourrates = pgTable("hourrates", {
name: text("name").notNull(),
purchase_price: doublePrecision("purchasePrice").notNull(),
purchasePrice: doublePrecision("purchasePrice").notNull(),
sellingPrice: doublePrecision("sellingPrice").notNull(),
archived: boolean("archived").notNull().default(false),

View File

@@ -13,19 +13,15 @@ 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"
@@ -46,9 +42,7 @@ 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"
@@ -78,4 +72,3 @@ export * from "./staff_time_events"
export * from "./serialtypes"
export * from "./serialexecutions"
export * from "./public_links"
export * from "./wikipages"

View File

@@ -1,48 +0,0 @@
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

View File

@@ -1,39 +0,0 @@
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

View File

@@ -54,7 +54,6 @@ 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),

View File

@@ -74,48 +74,6 @@ 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"),
@@ -130,13 +88,10 @@ 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"),
@@ -161,10 +116,6 @@ export const tenants = pgTable(
.notNull()
.default(14),
taxEvaluationPeriod: text("taxEvaluationPeriod")
.notNull()
.default("monthly"),
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(),

View File

@@ -1,99 +0,0 @@
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

View File

@@ -6,6 +6,6 @@ export default defineConfig({
schema: "./db/schema",
out: "./db/migrations",
dbCredentials: {
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
url: secrets.DATABASE_URL,
},
})

View File

@@ -5,14 +5,9 @@
"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",
"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"
"schema:index": "ts-node scripts/generate-schema-index.ts"
},
"repository": {
"type": "git",
@@ -32,6 +27,7 @@
"@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",
@@ -52,7 +48,6 @@
"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"

View File

@@ -1,95 +0,0 @@
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)
})

View File

@@ -1,270 +0,0 @@
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()
})

View File

@@ -1,265 +0,0 @@
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()
}
})

Binary file not shown.

View File

@@ -1,5 +1,6 @@
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";
@@ -28,7 +29,6 @@ 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,11 +42,9 @@ 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";
@@ -54,7 +52,6 @@ import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3";
//Services
import servicesPlugin from "./plugins/services";
@@ -73,6 +70,8 @@ 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);
@@ -108,7 +107,6 @@ 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)
@@ -117,10 +115,8 @@ 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
@@ -145,7 +141,6 @@ async function main() {
await subApp.register(userRoutes);
await subApp.register(publiclinksAuthenticatedRoutes);
await subApp.register(resourceRoutes);
await subApp.register(wikiRoutes);
},{prefix: "/api"})

View File

@@ -19,243 +19,241 @@ import {
and,
} from "drizzle-orm"
let badMessageDetected = false
let badMessageMessageSent = false
export function syncDokuboxService (server: FastifyInstance) {
let badMessageDetected = false
let badMessageMessageSent = false
let client: ImapFlow | null = null
let client: ImapFlow | null = null
// -------------------------------------------------------------
// 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
})
async function initDokuboxClient() {
if (client?.usable) {
return client
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")
}
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
})
// -------------------------------
// TENANTS LADEN (DRIZZLE)
// -------------------------------
const tenantList = await server.db
.select({
id: tenants.id,
name: tenants.name,
emailAddresses: tenants.dokuboxEmailAddresses,
key: tenants.dokuboxkey
})
.from(tenants)
console.log("Dokubox E-Mail Client Initialized")
const lock = await client.getMailboxLock("INBOX")
await client.connect()
return client
}
try {
const syncDokubox = async () => {
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
console.log("Perform Dokubox Sync")
const parsed = await simpleParser(msg.source)
await initDokuboxClient()
if (!client?.usable) {
throw new Error("E-Mail Client not usable")
}
// -------------------------------
// 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
)
}
}
const message = {
id: msg.uid,
subject: parsed.subject,
to: parsed.to?.value || [],
cc: parsed.cc?.value || [],
attachments: parsed.attachments || []
}
if (!badMessageDetected) {
badMessageDetected = false
badMessageMessageSent = false
// -------------------------------------------------
// MAPPING / FIND TENANT
// -------------------------------------------------
const config = await getMessageConfigDrizzle(server, message, tenantList)
if (!config) {
badMessageDetected = true
if (!badMessageMessageSent) {
badMessageMessageSent = true
}
return
}
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[] = []
if (message.to) {
message.to.forEach((item) =>
possibleKeys.push(item.address.split("@")[0].toLowerCase())
)
}
if (message.cc) {
message.cc.forEach((item) =>
possibleKeys.push(item.address.split("@")[0].toLowerCase())
)
}
// -------------------------------------------
// TENANT IDENTIFY
// -------------------------------------------
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
if (!tenant && message.to?.length) {
const address = message.to[0].address.toLowerCase()
tenant = tenantsList.find((t) =>
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
)
}
if (!tenant) return null
// -------------------------------------------
// 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),
and(
eq(folders.function, "incomingInvoices"),
//@ts-ignore
eq(folders.year, dayjs().format("YYYY"))
if (message.attachments.length > 0) {
for (const attachment of message.attachments) {
await saveFile(
server,
config.tenant,
message.id,
attachment,
config.folder,
config.filetype
)
)
)
.limit(1)
}
}
}
folderId = folder[0]?.id ?? null
if (!badMessageDetected) {
badMessageDetected = false
badMessageMessageSent = false
}
const tag = await server.db
.select({ id: filetags.id })
.from(filetags)
.where(
and(
eq(filetags.tenant, tenant.id),
eq(filetags.incomingDocumentType, "invoices")
)
)
.limit(1)
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
await client.messageDelete({ seen: true })
filetypeId = tag[0]?.id ?? null
}
// -------------------------------------------
// Mahnung
// -------------------------------------------
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")
)
)
.limit(1)
filetypeId = tag[0]?.id ?? null
}
// -------------------------------------------
// Sonstige Dokumente → Deposit Folder
// -------------------------------------------
else {
const folder = await server.db
.select({ id: folders.id })
.from(folders)
.where(
and(
eq(folders.tenant, tenant.id),
eq(folders.function, "deposit")
)
)
.limit(1)
folderId = folder[0]?.id ?? null
}
return {
tenant: tenant.id,
folder: folderId,
filetype: filetypeId
} finally {
lock.release()
client.close()
}
}
// -------------------------------------------------------------
// TENANT ERKENNEN + FOLDER/FILETYPES (DRIZZLE VERSION)
// -------------------------------------------------------------
const getMessageConfigDrizzle = async (
server: FastifyInstance,
message,
tenantsList: any[]
) => {
let possibleKeys: string[] = []
if (message.to) {
message.to.forEach((item) =>
possibleKeys.push(item.address.split("@")[0].toLowerCase())
)
}
if (message.cc) {
message.cc.forEach((item) =>
possibleKeys.push(item.address.split("@")[0].toLowerCase())
)
}
// -------------------------------------------
// TENANT IDENTIFY
// -------------------------------------------
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
if (!tenant && message.to?.length) {
const address = message.to[0].address.toLowerCase()
tenant = tenantsList.find((t) =>
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
)
}
if (!tenant) return null
// -------------------------------------------
// 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),
and(
eq(folders.function, "incomingInvoices"),
//@ts-ignore
eq(folders.year, dayjs().format("YYYY"))
)
)
)
.limit(1)
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")
)
)
.limit(1)
filetypeId = tag[0]?.id ?? null
}
// -------------------------------------------
// Mahnung
// -------------------------------------------
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")
)
)
.limit(1)
filetypeId = tag[0]?.id ?? null
}
// -------------------------------------------
// Sonstige Dokumente → Deposit Folder
// -------------------------------------------
else {
const folder = await server.db
.select({ id: folders.id })
.from(folders)
.where(
and(
eq(folders.tenant, tenant.id),
eq(folders.function, "deposit")
)
)
.limit(1)
folderId = folder[0]?.id ?? null
}
return {
run: async () => {
await syncDokubox()
console.log("Service: Dokubox sync finished")
}
tenant: tenant.id,
folder: folderId,
filetype: filetypeId
}
}

View File

@@ -12,18 +12,6 @@ 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")
@@ -106,9 +94,9 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
}
if (data.invoice_number) itemInfo.reference = data.invoice_number
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
if (data.invoice_date) itemInfo.date = dayjs(data.invoice_date).toISOString()
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
if (data.invoice_duedate) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
// Payment terms mapping
const mapPayment: any = {
@@ -121,26 +109,16 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
// 3.2 Positionszeilen konvertieren
if (data.invoice_items?.length > 0) {
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,
}
})
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,
}))
}
// 3.3 Beschreibung generieren
@@ -149,8 +127,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
if (data.reference) description += `Referenz: ${data.reference}\n`
if (data.invoice_items) {
for (const item of data.invoice_items) {
const line = formatInvoiceItemDescription(item)
if (line) description += `${line}\n`
description += `${item.description} - ${item.quantity} ${item.unit} - ${item.total}\n`
}
}
itemInfo.description = description.trim()

View File

@@ -1,7 +1,5 @@
// 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,
@@ -11,35 +9,30 @@ export async function getOrCreateContact(
if (!email && !phone) throw new Error('Contact must have at least an email or phone')
// Bestehenden Kontakt prüfen
const matchConditions = []
if (email) matchConditions.push(eq(helpdesk_contacts.email, email))
if (phone) matchConditions.push(eq(helpdesk_contacts.phone, phone))
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 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]
if (findError) throw findError
if (existing) return existing
// Anlegen
const created = await server.db
.insert(helpdesk_contacts)
.values({
tenantId: tenant_id,
const { data: created, error: insertError } = await server.supabase
.from('helpdesk_contacts')
.insert({
tenant_id,
email,
phone,
displayName: display_name,
customerId: customer_id,
contactId: contact_id
display_name,
customer_id,
contact_id
})
.returning()
.select()
.single()
return created[0]
if (insertError) throw insertError
return created
}

View File

@@ -2,8 +2,6 @@
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,
@@ -27,34 +25,24 @@ export async function createConversation(
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
const inserted = await server.db
.insert(helpdesk_conversations)
.values({
tenantId: tenant_id,
contactId: contactRecord.id,
channelInstanceId: channel_instance_id,
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.insert({
tenant_id,
contact_id: contactRecord.id,
channel_instance_id,
subject: subject || null,
status: 'open',
createdAt: new Date(),
customerId: customer_id,
contactPersonId: contact_person_id,
ticketNumber: usedNumber
created_at: new Date().toISOString(),
customer_id,
contact_person_id,
ticket_number: usedNumber
})
.returning()
.select()
.single()
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,
}
if (error) throw error
return data
}
export async function getConversations(
@@ -64,34 +52,22 @@ export async function getConversations(
) {
const { status, limit = 50 } = opts || {}
const filters = [eq(helpdesk_conversations.tenantId, tenant_id)]
if (status) filters.push(eq(helpdesk_conversations.status, status))
let query = server.supabase.from('helpdesk_conversations').select('*, customer_id(*)').eq('tenant_id', tenant_id)
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)
if (status) query = query.eq('status', status)
query = query.order('last_message_at', { ascending: false }).limit(limit)
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,
}))
const { data, error } = await query
if (error) throw error
const mappedData = data.map(entry => {
return {
...entry,
customer: entry.customer_id
}
})
return mappedData
}
export async function updateConversationStatus(
@@ -102,22 +78,13 @@ export async function updateConversationStatus(
const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed']
if (!valid.includes(status)) throw new Error('Invalid status')
const updated = await server.db
.update(helpdesk_conversations)
.set({ status })
.where(eq(helpdesk_conversations.id, conversation_id))
.returning()
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.update({ status })
.eq('id', conversation_id)
.select()
.single()
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,
}
if (error) throw error
return data
}

View File

@@ -1,7 +1,5 @@
// 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,
@@ -25,53 +23,38 @@ export async function addMessage(
) {
if (!payload?.text) throw new Error('Message payload requires text content')
const inserted = await server.db
.insert(helpdesk_messages)
.values({
tenantId: tenant_id,
conversationId: conversation_id,
authorUserId: author_user_id,
const { data: message, error } = await server.supabase
.from('helpdesk_messages')
.insert({
tenant_id,
conversation_id,
author_user_id,
direction,
payload,
rawMeta: raw_meta,
externalMessageId: external_message_id,
receivedAt: new Date(),
raw_meta,
created_at: new Date().toISOString(),
})
.returning()
.select()
.single()
const message = inserted[0]
if (error) throw error
// Letzte Nachricht aktualisieren
await server.db
.update(helpdesk_conversations)
.set({ lastMessageAt: new Date() })
.where(eq(helpdesk_conversations.id, conversation_id))
await server.supabase
.from('helpdesk_conversations')
.update({ last_message_at: new Date().toISOString() })
.eq('id', conversation_id)
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,
}
return message
}
export async function getMessages(server: FastifyInstance, conversation_id: string) {
const data = await server.db
.select()
.from(helpdesk_messages)
.where(eq(helpdesk_messages.conversationId, conversation_id))
.orderBy(asc(helpdesk_messages.createdAt))
const { data, error } = await server.supabase
.from('helpdesk_messages')
.select('*')
.eq('conversation_id', conversation_id)
.order('created_at', { ascending: true })
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,
}))
if (error) throw error
return data
}

View File

@@ -1,8 +1,6 @@
// 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';
@@ -36,16 +34,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 eventTypeRows = await this.server.db
.select()
.from(notificationsEventTypes)
.where(eq(notificationsEventTypes.eventKey, eventType))
.limit(1)
const eventTypeRow = eventTypeRows[0]
const { data: eventTypeRow, error: etErr } = await supabase
.from('notifications_event_types')
.select('event_key,is_active')
.eq('event_key', eventType)
.maybeSingle();
if (!eventTypeRow || eventTypeRow.isActive !== true) {
if (etErr || !eventTypeRow || eventTypeRow.is_active !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
}
@@ -56,40 +54,40 @@ export class NotificationService {
}
// 3) Notification anlegen (status: queued)
const insertedRows = await this.server.db
.insert(notificationsItems)
.values({
tenantId,
userId,
eventType,
const { data: inserted, error: insErr } = await supabase
.from('notifications_items')
.insert({
tenant_id: tenantId,
user_id: userId,
event_type: eventType,
title,
message,
payload: payload ?? null,
channel: 'email',
status: 'queued'
})
.returning({ id: notificationsItems.id })
const inserted = insertedRows[0]
.select('id')
.single();
if (!inserted) {
throw new Error("Fehler beim Einfügen der Notification");
if (insErr || !inserted) {
throw new Error(`Fehler beim Einfügen der Notification: ${insErr?.message}`);
}
// 4) E-Mail versenden
try {
await this.sendEmail(user.email, title, message);
await this.server.db
.update(notificationsItems)
.set({ status: 'sent', sentAt: new Date() })
.where(eq(notificationsItems.id, inserted.id));
await supabase
.from('notifications_items')
.update({ status: 'sent', sent_at: new Date().toISOString() })
.eq('id', inserted.id);
return { success: true, id: inserted.id };
} catch (err: any) {
await this.server.db
.update(notificationsItems)
.set({ status: 'failed', error: String(err?.message || err) })
.where(eq(notificationsItems.id, inserted.id));
await supabase
.from('notifications_items')
.update({ status: 'failed', error: String(err?.message || err) })
.eq('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' };

View File

@@ -1,5 +1,6 @@
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
@@ -9,7 +10,6 @@ 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 = documentTemplateHandlebars.compile(itemInfo.startText || "");
const templateEndText = documentTemplateHandlebars.compile(itemInfo.endText || "");
const templateStartText = Handlebars.compile(itemInfo.startText || "");
const templateEndText = Handlebars.compile(itemInfo.endText || "");
// --- 6. Title Sums Formatting ---
let returnTitleSums: Record<string, string> = {};

View File

@@ -1,249 +0,0 @@
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);
}

View File

@@ -1,9 +1,6 @@
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.
@@ -15,99 +12,26 @@ import { createHash } from "node:crypto";
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
*/
export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => {
const hashApiKey = (apiKey: string) =>
createHash("sha256").update(apiKey, "utf8").digest("hex")
//const allowedPrefix = opts.allowedPrefix || "/internal";
server.addHook("preHandler", async (req, reply) => {
try {
const apiKeyHeader = req.headers["x-api-key"];
const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
// Nur prüfen, wenn Route unterhalb des Prefix liegt
//if (!req.url.startsWith(allowedPrefix)) return;
if (!apiKey) {
const apiKey = req.headers["x-api-key"];
if (!apiKey || apiKey !== secrets.M2M_API_KEY) {
server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
return reply.status(401).send({ error: "Unauthorized" });
}
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))
}
// Zusatzinformationen im Request (z. B. interne Kennung)
(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);

View File

@@ -6,7 +6,6 @@ import { secrets } from "../utils/secrets"
import {
authUserRoles,
authRolePermissions,
authUsers,
} from "../../db/schema"
import { eq, and } from "drizzle-orm"
@@ -44,16 +43,6 @@ 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
@@ -77,13 +66,6 @@ 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" })
@@ -125,7 +107,6 @@ declare module "fastify" {
user_id: string
email: string
tenant_id: number | null
is_admin?: boolean
}
role: string
permissions: string[]

View File

@@ -9,15 +9,13 @@ 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.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","PATCH",
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"],
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin","Depth", "Overwrite", "Destination", "Lock-Token", "If"],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"],
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
credentials: true, // wichtig, falls du Cookies nutzt
});

View File

@@ -1,25 +1,31 @@
// 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
import fp from "fastify-plugin"
import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres"
import * as schema from "../../db/schema"
import {secrets} from "../utils/secrets";
import { Pool } from "pg"
export default fp(async (server, opts) => {
// Wir nutzen die db, die wir in src/db/index.ts erstellt haben
server.decorate("db", db);
const pool = new Pool({
connectionString: secrets.DATABASE_URL,
max: 10, // je nach Last
})
// Graceful Shutdown: Wenn Fastify ausgeht, schließen wir den Pool
const db = drizzle(pool , {schema})
// Dekorieren -> überall server.db
server.decorate("db", db)
// Graceful Shutdown
server.addHook("onClose", async () => {
console.log("[DB] Closing connection pool...");
await pool.end();
});
await pool.end()
})
console.log("[Fastify] Database attached from shared instance");
});
console.log("Drizzle database connected")
})
declare module "fastify" {
interface FastifyInstance {
db: NodePgDatabase<typeof schema>
db:NodePgDatabase<typeof schema>
}
}

View File

@@ -58,6 +58,8 @@ const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
const query = req.query as Record<string, any>
console.log(query)
// Pagination deaktivieren?
const disablePagination =
query.noPagination === 'true' ||

View File

@@ -1,7 +1,7 @@
// /plugins/services.ts
import fp from "fastify-plugin";
import { bankStatementService } from "../modules/cron/bankstatementsync.service";
import {syncDokuboxService} from "../modules/cron/dokuboximport.service";
//import {initDokuboxClient, syncDokubox} 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 syncDokuboxService>;
//dokuboxSync: ReturnType<typeof syncDokubox>;
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: syncDokuboxService(server),
//dokuboxSync: syncDokubox(server),
prepareIncomingInvoices: prepareIncomingInvoices(server),
});
});

View File

@@ -0,0 +1,19 @@
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;
}
}

View File

@@ -5,33 +5,26 @@ import swaggerUi from "@fastify/swagger-ui";
export default fp(async (server: FastifyInstance) => {
await server.register(swagger, {
mode: "dynamic",
mode: "dynamic", // wichtig: generiert echtes OpenAPI JSON
openapi: {
info: {
title: "FEDEO Backend API",
description: "OpenAPI specification for the FEDEO backend",
title: "Multi-Tenant API",
description: "API Dokumentation für dein Backend",
version: "1.0.0",
},
servers: [{ url: "/" }],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT"
}
}
}
servers: [{ url: "http://localhost:3000" }],
},
});
// @ts-ignore
await server.register(swaggerUi, {
routePrefix: "/docs",
});
// Stable raw spec path
server.get("/openapi.json", async (_req, reply) => {
return reply.send(server.swagger());
routePrefix: "/docs", // UI erreichbar unter http://localhost:3000/docs
swagger: {
info: {
title: "Multi-Tenant API",
version: "1.0.0",
},
},
exposeRoute: true,
});
});

View File

@@ -1,7 +1,5 @@
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) => {
@@ -11,12 +9,11 @@ export default fp(async (server: FastifyInstance) => {
return;
}
// Tenant aus DB laden
const rows = await server.db
.select()
.from(tenants)
.where(eq(tenants.portalDomain, host))
.limit(1);
const tenant = rows[0];
const { data: tenant } = await server.supabase
.from("tenants")
.select("*")
.eq("portalDomain", host)
.single();
if(!tenant) {

View File

@@ -1,761 +1,19 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { and, eq, inArray, isNull } from "drizzle-orm";
import { FastifyInstance } from "fastify";
import { eq } 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;
@@ -786,10 +44,11 @@ export default async function adminRoutes(server: FastifyInstance) {
await server.db
.insert(authTenantUsers)
// @ts-ignore
.values({
user_id: body.user_id,
tenant_id: body.tenant_id,
created_by: currentUser.id,
tenantId: body.tenant_id,
role: body.role ?? "member",
});
return { success: true, mode };
@@ -806,9 +65,6 @@ 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) {
@@ -838,7 +94,6 @@ export default async function adminRoutes(server: FastifyInstance) {
short: tenants.short,
locked: tenants.locked,
numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
extraModules: tenants.extraModules,
})
.from(authTenantUsers)

View File

@@ -1,60 +1,11 @@
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"],

View File

@@ -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 * 6,
maxAge: 60 * 60 * 3,
});
return { token };

View File

@@ -31,7 +31,6 @@ 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))
@@ -52,12 +51,9 @@ 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,

View File

@@ -4,19 +4,10 @@ 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 {
@@ -26,520 +17,6 @@ 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
@@ -694,35 +171,9 @@ 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: Number(createdRecord.bankstatement),
entityId: createdRecord.id,
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
@@ -765,7 +216,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(old.bankstatement),
entityId: id,
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,

View File

@@ -1,58 +0,0 @@
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 });
}
}
);
}

View File

@@ -1,39 +1,37 @@
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 { terminal_id } = req.params as { terminal_id: string };
const {rfid_id} = req.body as {rfid_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)
@@ -46,56 +44,55 @@ 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))
.where(
eq(stafftimeevents.user_id, profile.user_id)
)
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
.limit(1)
.then(rows => rows[0]);
// 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();
console.log(lastEvent)
// 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: actualEventTime, // Hier nutzen wir die berechnete Zeit
eventtype: nextEventType,
source: "TERMINAL" // Habe ich von WEB auf TERMINAL geändert (optional)
};
eventtime: new Date(),
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
source: "WEB"
}
console.log(`New Event for ${profile.user_id}: ${nextEventType} @ ${actualEventTime.toISOString()}`);
console.log(dataToInsert)
const [created] = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(dataToInsert)
.returning();
return created;
.returning()
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
}
);
}

View File

@@ -1,4 +1,6 @@
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"
@@ -7,8 +9,6 @@ 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) => {
try {
@@ -45,21 +45,25 @@ const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDat
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()
// 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(inserted[0])
console.log(data)
console.log(error)
} catch (error) {
console.log(error)
}
@@ -116,22 +120,9 @@ export default async function exportRoutes(server: FastifyInstance) {
//List Exports Available for Download
server.get("/exports", async (req,reply) => {
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))
const {data,error} = await server.supabase.from("exports").select().eq("tenant_id",req.user.tenant_id)
console.log(data)
console.log(data,error)
reply.send(data)
})

View File

@@ -2,12 +2,12 @@ import { FastifyInstance } from "fastify"
import multipart from "@fastify/multipart"
import { s3 } from "../utils/s3"
import {
GetObjectCommand
GetObjectCommand,
PutObjectCommand
} 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,28 +40,39 @@ 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
const created = await saveFile(
server,
tenantId,
null,
{
filename: data.filename,
content: fileBuffer,
contentType: data.mimetype
},
folder,
type,
otherMeta
)
// 1⃣ DB-Eintrag erzeugen
const inserted = await server.db
.insert(files)
.values({ tenant: tenantId })
.returning()
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: created.filename,
path: created.key
filename: data.filename,
path: fileKey
}
} catch (err) {
console.error(err)
@@ -237,7 +248,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// MULTIPLE PRESIGNED URLs
// -------------------------------------------------
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return { files: [] }
return reply.code(400).send({ error: "No ids provided" })
}
const rows = await server.db

View File

@@ -1,11 +1,6 @@
import { FastifyInstance } from "fastify";
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
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 {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import dayjs from "dayjs";
//import { ready as zplReady } from 'zpl-renderer-js'
//import { renderZPL } from "zpl-image";
@@ -18,12 +13,10 @@ 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, files} from "../../db/schema";
import {and, eq, isNull, not} from "drizzle-orm";
import {citys} from "../../db/schema";
import {eq} from "drizzle-orm";
import {useNextNumberRangeNumber} from "../utils/functions";
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)
@@ -32,40 +25,7 @@ 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
@@ -140,25 +100,31 @@ 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 (normalizedZip.length !== 5) {
return reply.code(400).send({ error: 'ZIP must contain exactly 5 digits' })
if (!zip) {
return reply.code(400).send({ error: 'ZIP is required' })
}
try {
const data = await server.db
//@ts-ignore
const data = await server.db.select().from(citys).where(eq(citys.zip,zip))
/*const { data, error } = await server.supabase
.from('citys')
.select()
.from(citys)
.where(eq(citys.zip, Number(normalizedZip)))
.eq('zip', zip)
.maybeSingle()
if (error) {
console.log(error)
return reply.code(500).send({ error: 'Database error' })
}*/
if (!data.length) {
if (!data) {
return reply.code(404).send({ error: 'ZIP not found' })
}
const city = data[0]
//districtMap
const bundeslaender = [
{ code: 'DE-BW', name: 'Baden-Württemberg' },
@@ -182,8 +148,9 @@ export default async function functionRoutes(server: FastifyInstance) {
return reply.send({
...city,
state_code: bundeslaender.find(i => i.name === city.countryName)?.code || null
...data,
//@ts-ignore
state_code: bundeslaender.find(i => i.name === data.countryName)
})
} catch (err) {
console.log(err)
@@ -191,55 +158,6 @@ 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}
@@ -261,77 +179,44 @@ 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
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)
)
)
/*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}
let processed = 0
let withText = 0
let errors = 0
console.log(widthMm,heightMm,dpmm)
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)
}
if (!zpl) {
return reply.code(400).send({ error: 'Missing ZPL string' })
}
return {
pending: pendingFiles.length,
processed,
withText,
errors
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' })
}
})
server.post('/functions/services/syncdokubox', async (req, reply) => {
await server.services.dokuboxSync.run()
})
server.post('/print/label', async (req, reply) => {
const { context, width = 584, height = 354 } = req.body as {context:any,width:number,height:number}
const { context, width=584, heigth=354 } = req.body as {context:any,width:number,heigth:number}
try {
const base64 = await generateLabel(context,width,height)
const base64 = await generateLabel(context,width,heigth)
return {
encoded: await encodeBase64ToNiimbot(base64, 'top'),
base64: base64
}
} catch (err) {
console.error('[Label Render Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render label' })
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
}
})
})*/
}

View File

@@ -3,11 +3,12 @@ import { FastifyInstance } from "fastify";
export default async function routes(server: FastifyInstance) {
server.get("/ping", async () => {
// Testquery gegen DB
const result = await server.db.execute("SELECT NOW()");
const { data, error } = await server.supabase.from("tenants").select("id").limit(1);
return {
status: "ok",
db: JSON.stringify(result.rows[0]),
db: error ? "not connected" : "connected",
tenant_count: data?.length ?? 0
};
});
}

View File

@@ -3,9 +3,8 @@ 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 { findCustomerOrContactByEmailOrDomain } from "../utils/helpers";
import { eq } from "drizzle-orm";
import { helpdesk_conversations, helpdesk_messages } from "../../db/schema";
import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers";
import {useNextNumberRangeNumber} from "../utils/functions";
// -------------------------------------------------------------
// 📧 Interne M2M-Route für eingehende E-Mails
@@ -53,12 +52,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
// 3⃣ Konversation anhand In-Reply-To suchen
let conversationId: string | null = null
if (in_reply_to) {
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
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
}
// 4⃣ Neue Konversation anlegen falls keine existiert
@@ -74,12 +73,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
})
conversationId = conversation.id
} else {
const rows = await server.db
.select()
.from(helpdesk_conversations)
.where(eq(helpdesk_conversations.id, conversationId))
.limit(1)
conversation = rows[0]
const { data } = await server.supabase
.from('helpdesk_conversations')
.select('*')
.eq('id', conversationId)
.single()
conversation = data
}
// 5⃣ Nachricht speichern
@@ -97,7 +96,7 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
return res.status(201).send({
success: true,
conversation_id: conversationId,
ticket_number: conversation?.ticket_number || conversation?.ticketNumber,
ticket_number: conversation.ticket_number,
})
})
}

View File

@@ -3,9 +3,70 @@ 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 { findCustomerOrContactByEmailOrDomain } from "../utils/helpers";
import { eq } from "drizzle-orm";
import { helpdesk_channel_instances } from "../../db/schema";
/**
* Ö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
}
const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
// Öffentliche POST-Route
@@ -24,18 +85,17 @@ const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
}
// 1⃣ Kanalinstanz anhand des Tokens ermitteln
const channels = await server.db
.select()
.from(helpdesk_channel_instances)
.where(eq(helpdesk_channel_instances.publicToken, public_token))
.limit(1)
const channel = channels[0]
const { data: channel, error: channelError } = await server.supabase
.from('helpdesk_channel_instances')
.select('*')
.eq('public_token', public_token)
.single()
if (!channel) {
if (channelError || !channel) {
return res.status(404).send({ error: 'Invalid channel token' })
}
const tenant_id = channel.tenantId
const tenant_id = channel.tenant_id
const channel_instance_id = channel.id
// @ts-ignore

View File

@@ -5,13 +5,6 @@ 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
@@ -65,30 +58,15 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const tenant_id = req.user?.tenant_id
const {id: conversation_id} = req.params as {id: string}
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))
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.select('*, helpdesk_contacts(*)')
.eq('tenant_id', tenant_id)
.eq('id', conversation_id)
.single()
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,
})
if (error) return res.status(404).send({ error: 'Conversation not found' })
return res.send(data)
})
// 🔄 4. Konversation Status ändern
@@ -203,39 +181,36 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
}
const inserted = await server.db
.insert(helpdesk_channel_instances)
.values({
tenantId: tenant_id,
typeId: type_id,
// Speichern in Supabase
const { data, error } = await server.supabase
.from("helpdesk_channel_instances")
.insert({
tenant_id,
type_id,
name,
config: safeConfig,
isActive: is_active,
is_active,
})
.returning()
.select()
.single()
const data = inserted[0]
if (!data) throw new Error("Konnte Channel nicht erstellen")
const responseConfig: any = data.config
if (error) throw error
// sensible Felder aus Response entfernen
if (responseConfig?.imap) {
delete responseConfig.imap.host
delete responseConfig.imap.user
delete responseConfig.imap.pass
if (data.config?.imap) {
delete data.config.imap.host
delete data.config.imap.user
delete data.config.imap.pass
}
if (responseConfig?.smtp) {
delete responseConfig.smtp.host
delete responseConfig.smtp.user
delete responseConfig.smtp.pass
if (data.config?.smtp) {
delete data.config.smtp.host
delete data.config.smtp.user
delete data.config.smtp.pass
}
reply.send({
message: "E-Mail-Channel erfolgreich erstellt",
channel: {
...data,
config: responseConfig
},
channel: data,
})
} catch (err) {
console.error("Fehler bei Channel-Erstellung:", err)
@@ -259,29 +234,29 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const { text } = req.body as { text: string }
// 🔹 Konversation inkl. Channel + Kontakt laden
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]
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()
console.log(conv)
if (!conv) {
if (convErr || !conv) {
reply.status(404).send({ error: "Konversation nicht gefunden" })
return
}
const contact = conv.contact as unknown as {email: string}
const channel = conv.channel as unknown as {name: string, config: any}
const contact = conv.helpdesk_contacts as unknown as {email: string}
const channel = conv.helpdesk_channel_instances as unknown as {name: string}
console.log(contact)
if (!contact?.email) {
@@ -313,7 +288,7 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const mailOptions = {
from: `"${channel?.name}" <${user}>`,
to: contact.email,
subject: `${conv.conversation.ticketNumber} | ${conv.conversation.subject}` || `${conv.conversation.ticketNumber} | Antwort vom FEDEO Helpdesk`,
subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`,
text,
}
@@ -321,22 +296,24 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
// 💾 Nachricht speichern
await server.db
.insert(helpdesk_messages)
.values({
tenantId: conv.conversation.tenantId,
conversationId: conversationId,
const { error: insertErr } = await server.supabase
.from("helpdesk_messages")
.insert({
tenant_id: conv.tenant_id,
conversation_id: conversationId,
direction: "outgoing",
payload: { type: "text", text },
externalMessageId: info.messageId,
receivedAt: new Date(),
external_message_id: info.messageId,
received_at: new Date().toISOString(),
})
if (insertErr) throw insertErr
// 🔁 Konversation aktualisieren
await server.db
.update(helpdesk_conversations)
.set({ lastMessageAt: new Date() })
.where(eq(helpdesk_conversations.id, conversationId))
await server.supabase
.from("helpdesk_conversations")
.update({ last_message_at: new Date().toISOString() })
.eq("id", conversationId)
reply.send({
message: "E-Mail erfolgreich gesendet",

View File

@@ -1,39 +1,12 @@
// 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, 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> = {
const columnMap: Record<string, string> = {
customers: "customer",
members: "customer",
vendors: "vendor",
projects: "project",
plants: "plant",
contracts: "contract",
contacts: "contact",
tasks: "task",
vehicles: "vehicle",
@@ -42,61 +15,17 @@ const insertFieldMap: Record<string, string> = {
products: "product",
inventoryitems: "inventoryitem",
inventoryitemgroups: "inventoryitemgroup",
absencerequests: "absencerequest",
checks: "check",
costcentres: "costcentre",
ownaccounts: "ownaccount",
documentboxes: "documentbox",
hourrates: "hourrate",
services: "service",
customerspaces: "customerspace",
customerinventoryitems: "customerinventoryitem",
memberrelations: "memberrelation",
}
const parseId = (value: string) => {
if (/^\d+$/.test(value)) return Number(value)
return value
}
roles: "role",
};
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", {
@@ -120,36 +49,29 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: `History not supported for resource '${resource}'` });
}
const data = await server.db
.select()
.from(historyitems)
.where(eq(column, parseId(id)))
.orderBy(asc(historyitems.createdAt));
const { data, error } = await server.supabase
.from("historyitems")
.select("*")
.eq(column, id)
.order("created_at", { ascending: true });
const userIds = Array.from(
new Set(data.map((item) => item.createdBy).filter(Boolean))
) as string[]
if (error) {
server.log.error(error);
return reply.code(500).send({ error: "Failed to fetch history" });
}
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 {data:users, error:usersError} = await server.supabase
.from("auth_users")
.select("*, auth_profiles(*), tenants!auth_tenant_users(*)")
const profileByUserId = new Map(
profiles.map((profile) => [profile.user_id, profile])
)
const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id))
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,
}))
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
}
})
@@ -206,33 +128,29 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
const userId = (req.user as any)?.user_id;
const fkField = insertFieldMap[resource];
const fkField = columnMap[resource];
if (!fkField) {
return reply.code(400).send({ error: `Unknown resource: ${resource}` });
}
const inserted = await server.db
.insert(historyitems)
.values({
const { data, error } = await server.supabase
.from("historyitems")
.insert({
text,
[fkField]: parseId(id),
[fkField]: id,
oldVal: old_val || null,
newVal: new_val || null,
config: config || null,
tenant: (req.user as any)?.tenant_id,
createdBy: userId
created_by: userId
})
.returning()
.select()
.single();
const data = inserted[0]
if (!data) {
return reply.code(500).send({ error: "Failed to create history entry" });
if (error) {
return reply.code(500).send({ error: error.message });
}
return reply.code(201).send({
...data,
created_at: data.createdAt,
created_by: data.createdBy
});
return reply.code(201).send(data);
});
}

View File

@@ -1,63 +0,0 @@
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" })
}
})
}

View File

@@ -1,22 +1,21 @@
// 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 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;
const { data, error } = await server.supabase
.from('auth_users')
.select('email')
.eq('id', userId)
.maybeSingle();
if (error || !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) => {

View File

@@ -1,19 +1,40 @@
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) {
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 });
// 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
});
}
server.log.error(error);
return reply.code(500).send({ error: "Interner Server Fehler" });
@@ -22,31 +43,49 @@ 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;
const body = req.body as any;
// Der Body enthält { profile, project, service, ... }
const payload = req.body;
console.log(payload)
try {
const quantity = parseFloat(body.quantity) || 0;
// 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')
};
// Service aufrufen (führt die 3 Schritte aus: Lieferschein -> Zeit -> History)
const result = await publicLinkService.submitFormData(server, token, payload, pin);
// 201 Created zurückgeben
return reply.code(201).send(result);
} catch (error: any) {
server.log.error(error);
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
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
});
}
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
import { FastifyInstance } from "fastify"
import { asc, desc, eq } from "drizzle-orm"
import { asc, desc } from "drizzle-orm"
import { sortData } from "../utils/sort"
// Schema imports
import { accounts, units, countrys, tenants } from "../../db/schema"
import { accounts, units,countrys } from "../../db/schema"
const TABLE_MAP: Record<string, any> = {
accounts,
@@ -35,49 +35,11 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
}
// ---------------------------------------
// 📌 SELECT: select-string wird in dieser Route bewusst ignoriert
// 📌 SELECT: wir ignorieren select string (wie Supabase)
// Drizzle kann kein dynamisches Select aus String!
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
// ---------------------------------------
if (resource === "accounts") {
const [tenant] = await server.db
.select({
accountChart: tenants.accountChart,
})
.from(tenants)
.where(eq(tenants.id, Number(req.user.tenant_id)))
.limit(1)
const activeAccountChart = tenant?.accountChart || "skr03"
let data
if (sort && (accounts as any)[sort]) {
const col = (accounts as any)[sort]
data = ascQuery === "true"
? await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
.orderBy(asc(col))
: await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
.orderBy(desc(col))
} else {
data = await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
}
return sortData(
data,
sort as any,
ascQuery === "true"
)
}
let query = server.db.select().from(table)
// ---------------------------------------

View File

@@ -124,7 +124,6 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
eventtype: "invalidated",
source: "WEB",
related_event_id: id,
invalidates_event_id: id,
metadata: {
reason: reason || "Bearbeitung",
replaced_by_edit: true

View File

@@ -1,7 +1,5 @@
import { FastifyInstance } from 'fastify'
import { StaffTimeEntryConnect } from '../../types/staff'
import { asc, eq } from "drizzle-orm";
import { stafftimenetryconnects } from "../../../db/schema";
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
@@ -10,21 +8,16 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/:id/connects',
async (req, reply) => {
const { id } = req.params
const { started_at, stopped_at, project_id, notes } = req.body
const parsedProjectId = project_id ? Number(project_id) : null
const { started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes } = req.body
const data = await server.db
.insert(stafftimenetryconnects)
.values({
stafftimeentry: id,
started_at: new Date(started_at),
stopped_at: new Date(stopped_at),
project_id: parsedProjectId,
notes
})
.returning()
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.insert([{ time_entry_id: id, started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes }])
.select()
.maybeSingle()
return reply.send(data[0])
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
@@ -33,12 +26,13 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/:id/connects',
async (req, reply) => {
const { id } = req.params
const data = await server.db
.select()
.from(stafftimenetryconnects)
.where(eq(stafftimenetryconnects.stafftimeentry, id))
.orderBy(asc(stafftimenetryconnects.started_at))
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.select('*')
.eq('time_entry_id', id)
.order('started_at', { ascending: true })
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
@@ -48,20 +42,15 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const patchData = { ...req.body } as any
if (patchData.started_at) patchData.started_at = new Date(patchData.started_at)
if (patchData.stopped_at) patchData.stopped_at = new Date(patchData.stopped_at)
if (patchData.project_id !== undefined) {
patchData.project_id = patchData.project_id ? Number(patchData.project_id) : null
}
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.update({ ...req.body, updated_at: new Date().toISOString() })
.eq('id', connectId)
.select()
.maybeSingle()
const data = await server.db
.update(stafftimenetryconnects)
.set({ ...patchData, updated_at: new Date() })
.where(eq(stafftimenetryconnects.id, connectId))
.returning()
return reply.send(data[0])
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
@@ -70,10 +59,12 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
await server.db
.delete(stafftimenetryconnects)
.where(eq(stafftimenetryconnects.id, connectId))
const { error } = await server.supabase
.from('staff_time_entry_connects')
.delete()
.eq('id', connectId)
if (error) return reply.code(400).send({ error: error.message })
return reply.send({ success: true })
}
)

Some files were not shown because too many files have changed in this diff Show More