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 name: 🐛 Bug Report
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern. about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
title: '[BUG] ' title: '[BUG] '
labels: bug labels: Problem
assignees: '' assignees: ''
--- ---
**Beschreibung** **Beschreibung**
Eine klare und prägnante Beschreibung des Fehlers.
**Reproduktion** **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** **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.** **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 name: ✨ Feature Request
about: Schlage eine Idee für dieses Projekt vor. about: Schlage eine Idee für dieses Projekt vor.
title: '[FEATURE] ' title: '[FEATURE] '
labels: enhancement labels: Funktionswunsch
assignees: '' assignees: ''
--- ---
**Ist dein Feature-Wunsch mit einem Problem verbunden?** **Ist dein Feature-Wunsch mit einem Problem verbunden?**
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
**Lösungsvorschlag** **Lösungsvorschlag**
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
**Alternativen** **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` ## ENV Vars
- `backend`: Node/Fastify-API auf Port `3100`
- `db`: PostgreSQL
- `traefik`: Reverse Proxy mit automatischen Let's-Encrypt-Zertifikaten
- optional `minio`: S3-kompatibler Objektspeicher fur Dateiuploads
Die Konfiguration erfolgt uber Umgebungsvariablen beziehungsweise eine `.env`-Datei im Deploy-Verzeichnis. - DOMAIN
- PDF_LICENSE
- DB_PASS
- DB_USER
- CONTACT_EMAIL
## Voraussetzungen ## Docker Compose File
~~~
Vor dem Deployment sollten folgende Punkte erfullt sein:
- Ein Linux-Server oder VPS mit offentlichen Ports `80` und `443`
- Docker Engine inkl. Compose Plugin
- Eine Domain, die auf den Server zeigt, z. B. `app.example.com`
- Optional: SMTP-Zugang fur E-Mails
- Optional: S3-Bucket oder MinIO fur Dateispeicher
Empfohlen:
- mindestens 2 vCPU
- mindestens 4 GB RAM
- SSD-Speicher fur PostgreSQL und Dateiuploads
## DNS und Netzwerk
Lege mindestens einen A- oder AAAA-Record an:
- `app.example.com -> <SERVER-IP>`
Traefik terminiert TLS direkt im Compose-Stack. Es ist kein zusatzlicher Reverse Proxy davor erforderlich.
## Benotigte Backend-Umgebungsvariablen
Das Backend erwartet mindestens diese Umgebungsvariablen:
- `COOKIE_SECRET`
- `JWT_SECRET`
- `PORT`
- `HOST`
- `DATABASE_URL`
- `S3_BUCKET`
- `ENCRYPTION_KEY`
- `MAILER_SMTP_HOST`
- `MAILER_SMTP_PORT`
- `MAILER_SMTP_SSL`
- `MAILER_SMTP_USER`
- `MAILER_SMTP_PASS`
- `MAILER_FROM`
- `S3_ENDPOINT`
- `S3_REGION`
- `S3_ACCESS_KEY`
- `S3_SECRET_KEY`
- `M2M_API_KEY`
- `API_BASE_URL`
- `GOCARDLESS_BASE_URL`
- `GOCARDLESS_SECRET_ID`
- `GOCARDLESS_SECRET_KEY`
- `DOKUBOX_IMAP_HOST`
- `DOKUBOX_IMAP_PORT`
- `DOKUBOX_IMAP_SECURE`
- `DOKUBOX_IMAP_USER`
- `DOKUBOX_IMAP_PASSWORD`
- `OPENAI_API_KEY`
- `STIRLING_API_KEY`
Minimal wichtige Werte fur den ersten Start:
- `HOST=0.0.0.0`
- `PORT=3100`
- `DATABASE_URL=postgres://fedeo:<starkes-passwort>@db:5432/fedeo`
- `API_BASE_URL=https://app.example.com/backend`
Wenn du MinIO verwendest, setze zusatzlich:
- `S3_ENDPOINT=http://minio:9000`
- `S3_REGION=eu-central-1`
- `S3_ACCESS_KEY=<MINIO_ROOT_USER>`
- `S3_SECRET_KEY=<MINIO_ROOT_PASSWORD>`
- `S3_BUCKET=fedeo`
## Deploy-Struktur
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
Beispiel:
```bash
git clone <DEIN-REPO-URL> /opt/fedeo
cd /opt/fedeo
```
Die Verzeichnisstruktur sollte dann mindestens so aussehen:
```text
/opt/fedeo/
docker-compose.yml
.env
backend/
frontend/
traefik/
letsencrypt/
logs/
postgres/
minio/
```
Danach:
```bash
mkdir -p /opt/fedeo/traefik/letsencrypt
mkdir -p /opt/fedeo/traefik/logs
mkdir -p /opt/fedeo/postgres
mkdir -p /opt/fedeo/minio
touch /opt/fedeo/traefik/letsencrypt/acme.json
chmod 600 /opt/fedeo/traefik/letsencrypt/acme.json
```
## Beispiel `.env`
Diese Datei liegt neben der `docker-compose.yml`:
```env
DOMAIN=app.example.com
CONTACT_EMAIL=admin@example.com
DB_NAME=fedeo
DB_USER=fedeo
DB_PASSWORD=change-this-db-password
DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo
MINIO_ROOT_USER=fedeo-minio
MINIO_ROOT_PASSWORD=change-this-minio-password
MINIO_BUCKET=fedeo
HOST=0.0.0.0
PORT=3100
COOKIE_SECRET=change-this-cookie-secret
JWT_SECRET=change-this-jwt-secret
ENCRYPTION_KEY=change-this-encryption-key
MAILER_SMTP_HOST=smtp.example.com
MAILER_SMTP_PORT=587
MAILER_SMTP_SSL=false
MAILER_SMTP_USER=mailer@example.com
MAILER_SMTP_PASS=change-this-mail-password
MAILER_FROM=FEDEO <no-reply@example.com>
S3_ENDPOINT=http://minio:9000
S3_REGION=eu-central-1
S3_ACCESS_KEY=fedeo-minio
S3_SECRET_KEY=change-this-minio-password
S3_BUCKET=fedeo
M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend
GOCARDLESS_BASE_URL=https://api.gocardless.com
GOCARDLESS_SECRET_ID=replace-this
GOCARDLESS_SECRET_KEY=replace-this
DOKUBOX_IMAP_HOST=imap.example.com
DOKUBOX_IMAP_PORT=993
DOKUBOX_IMAP_SECURE=true
DOKUBOX_IMAP_USER=dokubox@example.com
DOKUBOX_IMAP_PASSWORD=change-this-imap-password
OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
```
## Vollstandiges Docker Compose mit optionaler S3-MinIO-Option
Hinweis: Der Stack unten startet MinIO standardmassig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
```yaml
services: services:
traefik: frontend:
image: traefik:v2.11 image: git.federspiel.tech/flfeders/fedeo/frontend:main
container_name: fedeo-traefik restart: always
restart: unless-stopped environment:
command: - NUXT_PUBLIC_API_BASE=https://${DOMAIN}/backend
- --api.insecure=false - NUXT_PUBLIC_PDF_LICENSE=${PDF_LICENSE}
- --api.dashboard=false networks:
- --providers.docker=true - traefik
- --providers.docker.exposedbydefault=false labels:
- --entrypoints.web.address=:80 - "traefik.enable=true"
- --entrypoints.websecure.address=:443 - "traefik.docker.network=traefik"
- --entrypoints.web.http.redirections.entrypoint.to=websecure - "traefik.port=3000"
- --entrypoints.web.http.redirections.entrypoint.scheme=https # Middlewares
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true - "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
- --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL} # Web Entrypoint
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json - "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
- --accesslog=true - "traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
- --accesslog.filepath=/logs/access.log - "traefik.http.routers.fedeo-frontend.entrypoints=web"
ports: # Web Secure Entrypoint
- "80:80" - "traefik.http.routers.fedeo-frontend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
- "443:443" - "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
volumes: - "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
- ./traefik/letsencrypt:/letsencrypt backend:
- ./traefik/logs:/logs image: git.federspiel.tech/flfeders/fedeo/backend:main
- /var/run/docker.sock:/var/run/docker.sock:ro restart: always
networks: environment:
- web - INFISICAL_CLIENT_ID=
- INFISICAL_CLIENT_SECRET=
db: - NODE_ENV=production
image: postgres:16 networks:
container_name: fedeo-db - traefik
restart: unless-stopped labels:
environment: - "traefik.enable=true"
POSTGRES_DB: ${DB_NAME} - "traefik.docker.network=traefik"
POSTGRES_USER: ${DB_USER} - "traefik.port=3100"
POSTGRES_PASSWORD: ${DB_PASSWORD} # Middlewares
volumes: - "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
- ./postgres:/var/lib/postgresql/data - "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
healthcheck: # Web Entrypoint
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] - "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
interval: 10s - "traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
timeout: 5s - "traefik.http.routers.fedeo-backend.entrypoints=web"
retries: 10 # Web Secure Entrypoint
networks: - "traefik.http.routers.fedeo-backend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
- internal - "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
minio: - "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
image: minio/minio:latest # db:
container_name: fedeo-minio # image: postgres
restart: unless-stopped # restart: always
command: server /data --console-address ":9001" # shm_size: 128mb
environment: # environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER} # POSTGRES_PASSWORD:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} # POSTGRES_USER:
volumes: # POSTGRES_DB:
- ./minio:/data # volumes:
healthcheck: # - ./pg-data:/var/lib/postgresql/data
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] # ports:
interval: 10s # - "5432:5432"
timeout: 5s traefik:
retries: 10 image: traefik:v2.11
networks: restart: unless-stopped
- internal container_name: traefik
command:
createbuckets: - "--api.insecure=false"
image: minio/mc:latest - "--api.dashboard=false"
container_name: fedeo-minio-init - "--api.debug=false"
depends_on: - "--providers.docker=true"
minio: - "--providers.docker.exposedbydefault=false"
condition: service_healthy - "--providers.docker.network=traefik"
entrypoint: > - "--entrypoints.web.address=:80"
/bin/sh -c " - "--entrypoints.web-secured.address=:443"
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD}; - "--accesslog=true"
mc mb --ignore-existing local/${MINIO_BUCKET}; - "--accesslog.filepath=/logs/access.log"
mc anonymous set private local/${MINIO_BUCKET}; - "--accesslog.bufferingsize=5000"
exit 0; - "--accesslog.fields.defaultMode=keep"
" - "--accesslog.fields.headers.defaultMode=keep"
restart: "no" - "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
networks: - "--certificatesresolvers.mytlschallenge.acme.email=${CONTACT_EMAIL}"
- internal - "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
ports:
backend: - 80:80
build: - 443:443
context: ./backend volumes:
container_name: fedeo-backend - "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
restart: unless-stopped - "/var/run/docker.sock:/var/run/docker.sock:ro"
depends_on: - "./traefik/logs:/logs"
db: networks:
condition: service_healthy - traefik
minio:
condition: service_healthy
createbuckets:
condition: service_completed_successfully
environment:
NODE_ENV: production
HOST: ${HOST}
PORT: ${PORT}
COOKIE_SECRET: ${COOKIE_SECRET}
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
DATABASE_URL: ${DATABASE_URL}
MAILER_SMTP_HOST: ${MAILER_SMTP_HOST}
MAILER_SMTP_PORT: ${MAILER_SMTP_PORT}
MAILER_SMTP_SSL: ${MAILER_SMTP_SSL}
MAILER_SMTP_USER: ${MAILER_SMTP_USER}
MAILER_SMTP_PASS: ${MAILER_SMTP_PASS}
MAILER_FROM: ${MAILER_FROM}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_REGION: ${S3_REGION}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET}
M2M_API_KEY: ${M2M_API_KEY}
API_BASE_URL: ${API_BASE_URL}
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID}
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}
DOKUBOX_IMAP_HOST: ${DOKUBOX_IMAP_HOST}
DOKUBOX_IMAP_PORT: ${DOKUBOX_IMAP_PORT}
DOKUBOX_IMAP_SECURE: ${DOKUBOX_IMAP_SECURE}
DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER}
DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD}
OPENAI_API_KEY: ${OPENAI_API_KEY}
STIRLING_API_KEY: ${STIRLING_API_KEY}
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
- traefik.http.routers.fedeo-backend.entrypoints=websecure
- traefik.http.routers.fedeo-backend.tls.certresolver=letsencrypt
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
networks:
- web
- internal
frontend:
build:
context: ./frontend
container_name: fedeo-frontend
restart: unless-stopped
depends_on:
- backend
environment:
NODE_ENV: production
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
networks:
- web
networks: networks:
web: traefik:
driver: bridge external: false
internal: ~~~
driver: bridge
```
## Externe S3-Provider statt MinIO
Wenn du keinen lokalen MinIO-Container betreiben willst:
1. Entferne die Services `minio` und `createbuckets` aus der Compose-Datei.
2. Entferne im Backend `depends_on` fur `minio` und `createbuckets`.
3. Trage in `.env` die Zugangsdaten des externen S3-Dienstes ein.
Beispiel fur die relevanten Werte:
```env
S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
S3_REGION=eu-central-1
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
S3_BUCKET=fedeo
```
Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit MinIO und vielen S3-kompatiblen Providern. Bei reinem AWS S3 kann je nach Endpoint-Setup ein abweichendes Verhalten sinnvoll sein. Falls du AWS S3 einsetzen willst, sollte die S3-Initialisierung im Backend gegen den konkreten Zielprovider getestet werden.
## Start des Stacks
Im Deploy-Verzeichnis:
```bash
docker compose build
docker compose up -d
```
Danach Status prufen:
```bash
docker compose ps
docker compose logs -f traefik
docker compose logs -f backend
```
## Funktionsprufung
Nach dem ersten Start sollten mindestens diese Checks erfolgreich sein:
```bash
curl -I https://app.example.com
curl https://app.example.com/backend/health
```
Erwartung:
- Frontend liefert `200` oder `302`
- Backend liefert JSON wie `{"status":"ok"}`
## Updates
Bei neuen Versionen:
```bash
git pull
docker compose build
docker compose up -d
```
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
## Backup-Empfehlung
Regelmassig sichern:
- `./postgres`
- `./minio` falls MinIO lokal genutzt wird
- `./traefik/letsencrypt/acme.json`
- deine `.env`
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
## Bekannte Betriebsbesonderheiten
- Das Backend startet nur sauber, wenn alle Pflichtvariablen gesetzt sind.
- Ohne korrekt gesetzte S3-Secrets funktionieren Dateiuploads und dateibasierte Funktionen nicht.
- Fur die Frontend-PDF-Funktion wird eine gueltige `NUXT_PUBLIC_PDF_LICENSE` benotigt.
- PostgreSQL ist im Projekt vorgesehen; andere SQL-Datenbanken sind in dieser Compose-Datei nicht berucksichtigt.
## Optional: Nur mit bestehender externer Infrastruktur
Wenn bereits vorhanden:
- externer Reverse Proxy
- externer PostgreSQL-Server
- externer S3-Speicher
- externe Zertifikatsverwaltung
dann konnen `traefik`, `db` und `minio` aus dem Stack entfernt werden. In diesem Fall mussen die zugehorigen Hostnamen und Zugangsdaten in der `.env` beziehungsweise im Frontend-Environment auf die externe Infrastruktur zeigen.

1
backend/.gitignore vendored
View File

@@ -3,4 +3,3 @@ node_modules
.env .env
/src/generated/prisma /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 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 # Package-Dateien
COPY package*.json ./ COPY package*.json ./

View File

@@ -1,27 +1,13 @@
// src/db/index.ts import { drizzle } from "drizzle-orm/node-postgres"
import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"
import { Pool } from "pg";
import * as schema from "./schema";
import {secrets} from "../src/utils/secrets"; 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({ export const pool = new Pool({
connectionString, connectionString: secrets.DATABASE_URL,
max: 10, max: 10, // je nach Last
}); })
// TEST: Ist die DB wirklich da? export const db = drizzle(pool , {schema})
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 });

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, "when": 1765716877146,
"tag": "0004_stormy_onslaught", "tag": "0004_stormy_onslaught",
"breakpoints": true "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(), number: text("number").notNull(),
label: text("label").notNull(), label: text("label").notNull(),
accountChart: text("accountChart").notNull().default("skr03"),
description: text("description"), description: text("description"),
}) })

View File

@@ -12,7 +12,6 @@ export const authUsers = pgTable("auth_users", {
multiTenant: boolean("multi_tenant").notNull().default(true), multiTenant: boolean("multi_tenant").notNull().default(true),
must_change_password: boolean("must_change_password").notNull().default(false), must_change_password: boolean("must_change_password").notNull().default(false),
is_admin: boolean("is_admin").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }),

View File

@@ -11,7 +11,6 @@ import {
import { tenants } from "./tenants" import { tenants } from "./tenants"
import { customers } from "./customers" import { customers } from "./customers"
import { contacts } from "./contacts" import { contacts } from "./contacts"
import { contracttypes } from "./contracttypes"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
export const contracts = pgTable( export const contracts = pgTable(
@@ -49,9 +48,6 @@ export const contracts = pgTable(
contact: bigint("contact", { mode: "number" }).references( contact: bigint("contact", { mode: "number" }).references(
() => contacts.id () => contacts.id
), ),
contracttype: bigint("contracttype", { mode: "number" }).references(
() => contracttypes.id
),
bankingIban: text("bankingIban"), bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"), bankingBIC: text("bankingBIC"),
@@ -61,7 +57,6 @@ export const contracts = pgTable(
sepaDate: timestamp("sepaDate", { withTimezone: true }), sepaDate: timestamp("sepaDate", { withTimezone: true }),
paymentType: text("paymentType"), paymentType: text("paymentType"),
billingInterval: text("billingInterval"),
invoiceDispatch: text("invoiceDispatch"), invoiceDispatch: text("invoiceDispatch"),
ownFields: jsonb("ownFields").notNull().default({}), 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, jsonb,
boolean, boolean,
smallint, smallint,
doublePrecision,
uuid, uuid,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
@@ -97,7 +96,7 @@ export const createddocuments = pgTable("createddocuments", {
taxType: text("taxType"), taxType: text("taxType"),
customSurchargePercentage: doublePrecision("customSurchargePercentage") customSurchargePercentage: smallint("customSurchargePercentage")
.notNull() .notNull()
.default(0), .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" } from "drizzle-orm/pg-core"
import { tenants } from "./tenants" import { tenants } from "./tenants"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
import { memberrelations } from "./memberrelations"
export const customers = pgTable( export const customers = pgTable(
"customers", "customers",
@@ -63,8 +62,6 @@ export const customers = pgTable(
updatedBy: uuid("updated_by").references(() => authUsers.id), updatedBy: uuid("updated_by").references(() => authUsers.id),
customPaymentType: text("custom_payment_type"), // ENUM payment_types separat? 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, uuid,
timestamp, timestamp,
text, text,
bigint, jsonb, bigint,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { tenants } from "./tenants" import { tenants } from "./tenants"
@@ -23,11 +23,6 @@ export const devices = pgTable("devices", {
password: text("password"), password: text("password"),
externalId: text("externalId"), 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 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), documentbox: uuid("documentbox").references(() => documentboxes.id),
name: text("name"), name: text("name"),
extractedText: text("extracted_text"),
updatedAt: timestamp("updated_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id), updatedBy: uuid("updated_by").references(() => authUsers.id),
@@ -74,7 +73,6 @@ export const files = pgTable("files", {
createdBy: uuid("created_by").references(() => authUsers.id), createdBy: uuid("created_by").references(() => authUsers.id),
authProfile: uuid("auth_profile").references(() => authProfiles.id), authProfile: uuid("auth_profile").references(() => authProfiles.id),
size: bigint("size", { mode: "number" }),
}) })
export type File = typeof files.$inferSelect export type File = typeof files.$inferSelect

View File

@@ -20,8 +20,6 @@ import { tasks } from "./tasks"
import { vehicles } from "./vehicles" import { vehicles } from "./vehicles"
import { bankstatements } from "./bankstatements" import { bankstatements } from "./bankstatements"
import { spaces } from "./spaces" import { spaces } from "./spaces"
import { customerspaces } from "./customerspaces"
import { customerinventoryitems } from "./customerinventoryitems"
import { costcentres } from "./costcentres" import { costcentres } from "./costcentres"
import { ownaccounts } from "./ownaccounts" import { ownaccounts } from "./ownaccounts"
import { createddocuments } from "./createddocuments" import { createddocuments } from "./createddocuments"
@@ -34,7 +32,6 @@ import { events } from "./events"
import { inventoryitemgroups } from "./inventoryitemgroups" import { inventoryitemgroups } from "./inventoryitemgroups"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
import {files} from "./files"; import {files} from "./files";
import { memberrelations } from "./memberrelations";
export const historyitems = pgTable("historyitems", { export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" }) id: bigint("id", { mode: "number" })
@@ -102,12 +99,6 @@ export const historyitems = pgTable("historyitems", {
space: bigint("space", { mode: "number" }).references(() => spaces.id), 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"), config: jsonb("config"),
projecttype: bigint("projecttype", { mode: "number" }).references( projecttype: bigint("projecttype", { mode: "number" }).references(

View File

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

View File

@@ -13,19 +13,15 @@ export * from "./checks"
export * from "./citys" export * from "./citys"
export * from "./contacts" export * from "./contacts"
export * from "./contracts" export * from "./contracts"
export * from "./contracttypes"
export * from "./costcentres" export * from "./costcentres"
export * from "./countrys" export * from "./countrys"
export * from "./createddocuments" export * from "./createddocuments"
export * from "./createdletters" export * from "./createdletters"
export * from "./customers" export * from "./customers"
export * from "./customerspaces"
export * from "./customerinventoryitems"
export * from "./devices" export * from "./devices"
export * from "./documentboxes" export * from "./documentboxes"
export * from "./enums" export * from "./enums"
export * from "./events" export * from "./events"
export * from "./entitybankaccounts"
export * from "./files" export * from "./files"
export * from "./filetags" export * from "./filetags"
export * from "./folders" export * from "./folders"
@@ -46,9 +42,7 @@ export * from "./incominginvoices"
export * from "./inventoryitemgroups" export * from "./inventoryitemgroups"
export * from "./inventoryitems" export * from "./inventoryitems"
export * from "./letterheads" export * from "./letterheads"
export * from "./memberrelations"
export * from "./movements" export * from "./movements"
export * from "./m2m_api_keys"
export * from "./notifications_event_types" export * from "./notifications_event_types"
export * from "./notifications_items" export * from "./notifications_items"
export * from "./notifications_preferences" export * from "./notifications_preferences"
@@ -77,5 +71,4 @@ export * from "./vendors"
export * from "./staff_time_events" export * from "./staff_time_events"
export * from "./serialtypes" export * from "./serialtypes"
export * from "./serialexecutions" export * from "./serialexecutions"
export * from "./public_links" 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([]), materialComposition: jsonb("materialComposition").notNull().default([]),
personalComposition: jsonb("personalComposition").notNull().default([]), personalComposition: jsonb("personalComposition").notNull().default([]),
priceUpdateLocked: boolean("priceUpdateLocked").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id), updatedBy: uuid("updated_by").references(() => authUsers.id),

View File

@@ -74,48 +74,6 @@ export const tenants = pgTable(
timeTracking: true, timeTracking: true,
planningBoard: true, planningBoard: true,
workingTimeTracking: 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"), ownFields: jsonb("ownFields"),
@@ -130,13 +88,10 @@ export const tenants = pgTable(
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 }, confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 }, invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 }, spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 }, inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
customerinventoryitems: { prefix: "KIA-", suffix: "", nextNumber: 1000 },
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 }, projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 }, costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
}), }),
accountChart: text("accountChart").notNull().default("skr03"),
standardEmailForInvoices: text("standardEmailForInvoices"), standardEmailForInvoices: text("standardEmailForInvoices"),
@@ -161,10 +116,6 @@ export const tenants = pgTable(
.notNull() .notNull()
.default(14), .default(14),
taxEvaluationPeriod: text("taxEvaluationPeriod")
.notNull()
.default("monthly"),
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]), dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(), 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", schema: "./db/schema",
out: "./db/migrations", out: "./db/migrations",
dbCredentials: { dbCredentials: {
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo", url: secrets.DATABASE_URL,
}, },
}) })

View File

@@ -5,14 +5,9 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "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", "build": "tsc",
"start": "node dist/src/index.js", "start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts", "schema:index": "ts-node scripts/generate-schema-index.ts"
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -32,6 +27,7 @@
"@infisical/sdk": "^4.0.6", "@infisical/sdk": "^4.0.6",
"@mmote/niimbluelib": "^0.0.1-alpha.29", "@mmote/niimbluelib": "^0.0.1-alpha.29",
"@prisma/client": "^6.15.0", "@prisma/client": "^6.15.0",
"@supabase/supabase-js": "^2.56.1",
"@zip.js/zip.js": "^2.7.73", "@zip.js/zip.js": "^2.7.73",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"axios": "^1.12.1", "axios": "^1.12.1",
@@ -52,7 +48,6 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"webdav-server": "^2.6.2",
"xmlbuilder": "^15.1.1", "xmlbuilder": "^15.1.1",
"zpl-image": "^0.2.0", "zpl-image": "^0.2.0",
"zpl-renderer-js": "^2.0.2" "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 Fastify from "fastify";
import swaggerPlugin from "./plugins/swagger" import swaggerPlugin from "./plugins/swagger"
import supabasePlugin from "./plugins/supabase";
import dayjsPlugin from "./plugins/dayjs"; import dayjsPlugin from "./plugins/dayjs";
import healthRoutes from "./routes/health"; import healthRoutes from "./routes/health";
import meRoutes from "./routes/auth/me"; import meRoutes from "./routes/auth/me";
@@ -28,7 +29,6 @@ import staffTimeRoutes from "./routes/staff/time";
import staffTimeConnectRoutes from "./routes/staff/timeconnects"; import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user"; import userRoutes from "./routes/auth/user";
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated"; import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
import wikiRoutes from "./routes/wiki";
//Public Links //Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated"; 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 deviceRoutes from "./routes/internal/devices";
import tenantRoutesInternal from "./routes/internal/tenant"; import tenantRoutesInternal from "./routes/internal/tenant";
import staffTimeRoutesInternal from "./routes/internal/time"; import staffTimeRoutesInternal from "./routes/internal/time";
import authM2mInternalRoutes from "./routes/internal/auth.m2m";
//Devices //Devices
import devicesRFIDRoutes from "./routes/devices/rfid"; import devicesRFIDRoutes from "./routes/devices/rfid";
import devicesManagementRoutes from "./routes/devices/management";
import {sendMail} from "./utils/mailer"; import {sendMail} from "./utils/mailer";
@@ -54,7 +52,6 @@ import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer" import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3"; import {initS3} from "./utils/s3";
//Services //Services
import servicesPlugin from "./plugins/services"; import servicesPlugin from "./plugins/services";
@@ -73,6 +70,8 @@ async function main() {
// Plugins Global verfügbar // Plugins Global verfügbar
await app.register(swaggerPlugin); await app.register(swaggerPlugin);
await app.register(corsPlugin);
await app.register(supabasePlugin);
await app.register(tenantPlugin); await app.register(tenantPlugin);
await app.register(dayjsPlugin); await app.register(dayjsPlugin);
await app.register(dbPlugin); await app.register(dbPlugin);
@@ -108,7 +107,6 @@ async function main() {
await app.register(async (m2mApp) => { await app.register(async (m2mApp) => {
await m2mApp.register(authM2m) await m2mApp.register(authM2m)
await m2mApp.register(authM2mInternalRoutes)
await m2mApp.register(helpdeskInboundEmailRoutes) await m2mApp.register(helpdeskInboundEmailRoutes)
await m2mApp.register(deviceRoutes) await m2mApp.register(deviceRoutes)
await m2mApp.register(tenantRoutesInternal) await m2mApp.register(tenantRoutesInternal)
@@ -117,10 +115,8 @@ async function main() {
await app.register(async (devicesApp) => { await app.register(async (devicesApp) => {
await devicesApp.register(devicesRFIDRoutes) await devicesApp.register(devicesRFIDRoutes)
await devicesApp.register(devicesManagementRoutes)
},{prefix: "/devices"}) },{prefix: "/devices"})
await app.register(corsPlugin);
//Geschützte Routes //Geschützte Routes
@@ -145,7 +141,6 @@ async function main() {
await subApp.register(userRoutes); await subApp.register(userRoutes);
await subApp.register(publiclinksAuthenticatedRoutes); await subApp.register(publiclinksAuthenticatedRoutes);
await subApp.register(resourceRoutes); await subApp.register(resourceRoutes);
await subApp.register(wikiRoutes);
},{prefix: "/api"}) },{prefix: "/api"})
@@ -169,4 +164,4 @@ async function main() {
} }
} }
main(); main();

View File

@@ -19,243 +19,241 @@ import {
and, and,
} from "drizzle-orm" } from "drizzle-orm"
let badMessageDetected = false
let badMessageMessageSent = false
export function syncDokuboxService (server: FastifyInstance) { let client: ImapFlow | null = null
let badMessageDetected = false
let badMessageMessageSent = false
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() { console.log("Dokubox E-Mail Client Initialized")
if (client?.usable) {
return client 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, // TENANTS LADEN (DRIZZLE)
port: secrets.DOKUBOX_IMAP_PORT, // -------------------------------
secure: secrets.DOKUBOX_IMAP_SECURE, const tenantList = await server.db
auth: { .select({
user: secrets.DOKUBOX_IMAP_USER, id: tenants.id,
pass: secrets.DOKUBOX_IMAP_PASSWORD name: tenants.name,
}, emailAddresses: tenants.dokuboxEmailAddresses,
logger: false key: tenants.dokuboxkey
}) })
.from(tenants)
console.log("Dokubox E-Mail Client Initialized") const lock = await client.getMailboxLock("INBOX")
await client.connect() try {
return client
}
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() const message = {
id: msg.uid,
if (!client?.usable) { subject: parsed.subject,
throw new Error("E-Mail Client not usable") to: parsed.to?.value || [],
} cc: parsed.cc?.value || [],
attachments: parsed.attachments || []
// -------------------------------
// TENANTS LADEN (DRIZZLE)
// -------------------------------
const tenantList = await server.db
.select({
id: tenants.id,
name: tenants.name,
emailAddresses: tenants.dokuboxEmailAddresses,
key: tenants.dokuboxkey
})
.from(tenants)
const lock = await client.getMailboxLock("INBOX")
try {
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
const parsed = await simpleParser(msg.source)
const message = {
id: msg.uid,
subject: parsed.subject,
to: parsed.to?.value || [],
cc: parsed.cc?.value || [],
attachments: parsed.attachments || []
}
// -------------------------------------------------
// MAPPING / FIND TENANT
// -------------------------------------------------
const config = await getMessageConfigDrizzle(server, message, tenantList)
if (!config) {
badMessageDetected = true
if (!badMessageMessageSent) {
badMessageMessageSent = true
}
server.log.warn({ messageId: message.id, subject: message.subject }, "Dokubox message could not be mapped to a tenant")
continue
}
if (message.attachments.length > 0) {
for (const attachment of message.attachments) {
await saveFile(
server,
config.tenant,
message.id,
attachment,
config.folder,
config.filetype
)
}
}
} }
if (!badMessageDetected) { // -------------------------------------------------
badMessageDetected = false // MAPPING / FIND TENANT
badMessageMessageSent = false // -------------------------------------------------
const config = await getMessageConfigDrizzle(server, message, tenantList)
if (!config) {
badMessageDetected = true
if (!badMessageMessageSent) {
badMessageMessageSent = true
}
return
} }
await client.messageFlagsAdd({ seen: false }, ["\\Seen"]) if (message.attachments.length > 0) {
await client.messageDelete({ seen: true }) for (const attachment of message.attachments) {
await saveFile(
} finally { server,
lock.release() config.tenant,
client.close() message.id,
} attachment,
} config.folder,
config.filetype
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 if (!badMessageDetected) {
badMessageDetected = false
badMessageMessageSent = false
}
const tag = await server.db await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
.select({ id: filetags.id }) await client.messageDelete({ seen: true })
.from(filetags)
.where(
and(
eq(filetags.tenant, tenant.id),
eq(filetags.incomingDocumentType, "invoices")
)
)
.limit(1)
filetypeId = tag[0]?.id ?? null } finally {
} lock.release()
client.close()
// -------------------------------------------
// 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
} }
} }
// -------------------------------------------------------------
// 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 { return {
run: async () => { tenant: tenant.id,
await syncDokubox() folder: folderId,
console.log("Service: Dokubox sync finished") filetype: filetypeId
}
} }
} }

View File

@@ -12,18 +12,6 @@ import {
import { eq, and, isNull, not } from "drizzle-orm" 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) { export function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number) => { const processInvoices = async (tenantId:number) => {
console.log("▶ Starting Incoming Invoice Preparation") 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_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.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 // Payment terms mapping
const mapPayment: any = { const mapPayment: any = {
@@ -121,26 +109,16 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
// 3.2 Positionszeilen konvertieren // 3.2 Positionszeilen konvertieren
if (data.invoice_items?.length > 0) { if (data.invoice_items?.length > 0) {
itemInfo.accounts = data.invoice_items itemInfo.accounts = data.invoice_items.map(item => ({
.filter(item => item.description || item.total !== null || item.total_without_tax !== null) account: item.account_id,
.map(item => { description: item.description,
const total = typeof item.total === "number" ? item.total : null amountNet: item.total_without_tax,
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null amountTax: Number((item.total - item.total_without_tax).toFixed(2)),
const amountTax = total !== null && totalWithoutTax !== null taxType: String(item.tax_rate),
? Number((total - totalWithoutTax).toFixed(2)) amountGross: item.total,
: null costCentre: null,
quantity: item.quantity,
return { }))
account: item.account_id,
description: item.description,
amountNet: totalWithoutTax,
amountTax,
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
amountGross: total,
costCentre: null,
quantity: item.quantity,
}
})
} }
// 3.3 Beschreibung generieren // 3.3 Beschreibung generieren
@@ -149,8 +127,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
if (data.reference) description += `Referenz: ${data.reference}\n` if (data.reference) description += `Referenz: ${data.reference}\n`
if (data.invoice_items) { if (data.invoice_items) {
for (const item of data.invoice_items) { for (const item of data.invoice_items) {
const line = formatInvoiceItemDescription(item) description += `${item.description} - ${item.quantity} ${item.unit} - ${item.total}\n`
if (line) description += `${line}\n`
} }
} }
itemInfo.description = description.trim() itemInfo.description = description.trim()

View File

@@ -1,7 +1,5 @@
// modules/helpdesk/helpdesk.contact.service.ts // modules/helpdesk/helpdesk.contact.service.ts
import { FastifyInstance } from 'fastify' import { FastifyInstance } from 'fastify'
import { and, eq, or } from "drizzle-orm";
import { helpdesk_contacts } from "../../../db/schema";
export async function getOrCreateContact( export async function getOrCreateContact(
server: FastifyInstance, 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') if (!email && !phone) throw new Error('Contact must have at least an email or phone')
// Bestehenden Kontakt prüfen // Bestehenden Kontakt prüfen
const matchConditions = [] const { data: existing, error: findError } = await server.supabase
if (email) matchConditions.push(eq(helpdesk_contacts.email, email)) .from('helpdesk_contacts')
if (phone) matchConditions.push(eq(helpdesk_contacts.phone, phone)) .select('*')
.eq('tenant_id', tenant_id)
.or(`email.eq.${email || ''},phone.eq.${phone || ''}`)
.maybeSingle()
const existing = await server.db if (findError) throw findError
.select() if (existing) return existing
.from(helpdesk_contacts)
.where(
and(
eq(helpdesk_contacts.tenantId, tenant_id),
or(...matchConditions)
)
)
.limit(1)
if (existing[0]) return existing[0]
// Anlegen // Anlegen
const created = await server.db const { data: created, error: insertError } = await server.supabase
.insert(helpdesk_contacts) .from('helpdesk_contacts')
.values({ .insert({
tenantId: tenant_id, tenant_id,
email, email,
phone, phone,
displayName: display_name, display_name,
customerId: customer_id, customer_id,
contactId: contact_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 { FastifyInstance } from 'fastify'
import { getOrCreateContact } from './helpdesk.contact.service.js' import { getOrCreateContact } from './helpdesk.contact.service.js'
import {useNextNumberRangeNumber} from "../../utils/functions"; 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( export async function createConversation(
server: FastifyInstance, server: FastifyInstance,
@@ -27,34 +25,24 @@ export async function createConversation(
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets") const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
const inserted = await server.db const { data, error } = await server.supabase
.insert(helpdesk_conversations) .from('helpdesk_conversations')
.values({ .insert({
tenantId: tenant_id, tenant_id,
contactId: contactRecord.id, contact_id: contactRecord.id,
channelInstanceId: channel_instance_id, channel_instance_id,
subject: subject || null, subject: subject || null,
status: 'open', status: 'open',
createdAt: new Date(), created_at: new Date().toISOString(),
customerId: customer_id, customer_id,
contactPersonId: contact_person_id, contact_person_id,
ticketNumber: usedNumber ticket_number: usedNumber
}) })
.returning() .select()
.single()
const data = inserted[0] if (error) throw error
return data
return {
...data,
channel_instance_id: data.channelInstanceId,
contact_id: data.contactId,
contact_person_id: data.contactPersonId,
created_at: data.createdAt,
customer_id: data.customerId,
last_message_at: data.lastMessageAt,
tenant_id: data.tenantId,
ticket_number: data.ticketNumber,
}
} }
export async function getConversations( export async function getConversations(
@@ -64,34 +52,22 @@ export async function getConversations(
) { ) {
const { status, limit = 50 } = opts || {} const { status, limit = 50 } = opts || {}
const filters = [eq(helpdesk_conversations.tenantId, tenant_id)] let query = server.supabase.from('helpdesk_conversations').select('*, customer_id(*)').eq('tenant_id', tenant_id)
if (status) filters.push(eq(helpdesk_conversations.status, status))
const data = await server.db if (status) query = query.eq('status', status)
.select({ query = query.order('last_message_at', { ascending: false }).limit(limit)
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)
return data.map((entry) => ({ const { data, error } = await query
...entry.conversation, if (error) throw error
helpdesk_contacts: entry.contact,
channel_instance_id: entry.conversation.channelInstanceId, const mappedData = data.map(entry => {
contact_id: entry.conversation.contactId, return {
contact_person_id: entry.conversation.contactPersonId, ...entry,
created_at: entry.conversation.createdAt, customer: entry.customer_id
customer_id: entry.customer, }
last_message_at: entry.conversation.lastMessageAt, })
tenant_id: entry.conversation.tenantId,
ticket_number: entry.conversation.ticketNumber, return mappedData
}))
} }
export async function updateConversationStatus( export async function updateConversationStatus(
@@ -102,22 +78,13 @@ export async function updateConversationStatus(
const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed'] const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed']
if (!valid.includes(status)) throw new Error('Invalid status') if (!valid.includes(status)) throw new Error('Invalid status')
const updated = await server.db const { data, error } = await server.supabase
.update(helpdesk_conversations) .from('helpdesk_conversations')
.set({ status }) .update({ status })
.where(eq(helpdesk_conversations.id, conversation_id)) .eq('id', conversation_id)
.returning() .select()
.single()
const data = updated[0] if (error) throw error
return { return data
...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,
}
} }

View File

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

View File

@@ -1,8 +1,6 @@
// services/notification.service.ts // services/notification.service.ts
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import {secrets} from "../utils/secrets"; import {secrets} from "../utils/secrets";
import { eq } from "drizzle-orm";
import { notificationsEventTypes, notificationsItems } from "../../db/schema";
export type NotificationStatus = 'queued' | 'sent' | 'failed'; export type NotificationStatus = 'queued' | 'sent' | 'failed';
@@ -36,16 +34,16 @@ export class NotificationService {
*/ */
async trigger(input: TriggerInput) { async trigger(input: TriggerInput) {
const { tenantId, userId, eventType, title, message, payload } = input; const { tenantId, userId, eventType, title, message, payload } = input;
const supabase = this.server.supabase;
// 1) Event-Typ prüfen (aktiv?) // 1) Event-Typ prüfen (aktiv?)
const eventTypeRows = await this.server.db const { data: eventTypeRow, error: etErr } = await supabase
.select() .from('notifications_event_types')
.from(notificationsEventTypes) .select('event_key,is_active')
.where(eq(notificationsEventTypes.eventKey, eventType)) .eq('event_key', eventType)
.limit(1) .maybeSingle();
const eventTypeRow = eventTypeRows[0]
if (!eventTypeRow || eventTypeRow.isActive !== true) { if (etErr || !eventTypeRow || eventTypeRow.is_active !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`); throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
} }
@@ -56,40 +54,40 @@ export class NotificationService {
} }
// 3) Notification anlegen (status: queued) // 3) Notification anlegen (status: queued)
const insertedRows = await this.server.db const { data: inserted, error: insErr } = await supabase
.insert(notificationsItems) .from('notifications_items')
.values({ .insert({
tenantId, tenant_id: tenantId,
userId, user_id: userId,
eventType, event_type: eventType,
title, title,
message, message,
payload: payload ?? null, payload: payload ?? null,
channel: 'email', channel: 'email',
status: 'queued' status: 'queued'
}) })
.returning({ id: notificationsItems.id }) .select('id')
const inserted = insertedRows[0] .single();
if (!inserted) { if (insErr || !inserted) {
throw new Error("Fehler beim Einfügen der Notification"); throw new Error(`Fehler beim Einfügen der Notification: ${insErr?.message}`);
} }
// 4) E-Mail versenden // 4) E-Mail versenden
try { try {
await this.sendEmail(user.email, title, message); await this.sendEmail(user.email, title, message);
await this.server.db await supabase
.update(notificationsItems) .from('notifications_items')
.set({ status: 'sent', sentAt: new Date() }) .update({ status: 'sent', sent_at: new Date().toISOString() })
.where(eq(notificationsItems.id, inserted.id)); .eq('id', inserted.id);
return { success: true, id: inserted.id }; return { success: true, id: inserted.id };
} catch (err: any) { } catch (err: any) {
await this.server.db await supabase
.update(notificationsItems) .from('notifications_items')
.set({ status: 'failed', error: String(err?.message || err) }) .update({ status: 'failed', error: String(err?.message || err) })
.where(eq(notificationsItems.id, inserted.id)); .eq('id', inserted.id);
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen'); this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
return { success: false, error: err?.message || '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 dayjs from "dayjs";
import quarterOfYear from "dayjs/plugin/quarterOfYear"; import quarterOfYear from "dayjs/plugin/quarterOfYear";
import Handlebars from "handlebars";
import axios from "axios"; import axios from "axios";
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
@@ -9,7 +10,6 @@ import { saveFile } from "../utils/files";
import {FastifyInstance} from "fastify"; import {FastifyInstance} from "fastify";
import {useNextNumberRangeNumber} from "../utils/functions"; import {useNextNumberRangeNumber} from "../utils/functions";
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen! import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
import { documentTemplateHandlebars } from "../utils/handlebars";
dayjs.extend(quarterOfYear); dayjs.extend(quarterOfYear);
@@ -609,8 +609,8 @@ export function getDocumentDataBackend(
}; };
}; };
const templateStartText = documentTemplateHandlebars.compile(itemInfo.startText || ""); const templateStartText = Handlebars.compile(itemInfo.startText || "");
const templateEndText = documentTemplateHandlebars.compile(itemInfo.endText || ""); const templateEndText = Handlebars.compile(itemInfo.endText || "");
// --- 6. Title Sums Formatting --- // --- 6. Title Sums Formatting ---
let returnTitleSums: Record<string, string> = {}; 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 { FastifyInstance } from "fastify";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
import { secrets } from "../utils/secrets"; 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. * Fastify Plugin für Machine-to-Machine Authentifizierung.
@@ -15,99 +12,26 @@ import { createHash } from "node:crypto";
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' }) * server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
*/ */
export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => { export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => {
const hashApiKey = (apiKey: string) => //const allowedPrefix = opts.allowedPrefix || "/internal";
createHash("sha256").update(apiKey, "utf8").digest("hex")
server.addHook("preHandler", async (req, reply) => { server.addHook("preHandler", async (req, reply) => {
try { try {
const apiKeyHeader = req.headers["x-api-key"]; // Nur prüfen, wenn Route unterhalb des Prefix liegt
const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader; //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}`); server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
return reply.status(401).send({ error: "Unauthorized" }); return reply.status(401).send({ error: "Unauthorized" });
} }
const keyHash = hashApiKey(apiKey); // Zusatzinformationen im Request (z. B. interne Kennung)
const keyRows = await server.db
.select({
id: m2mApiKeys.id,
tenantId: m2mApiKeys.tenantId,
userId: m2mApiKeys.userId,
active: m2mApiKeys.active,
expiresAt: m2mApiKeys.expiresAt,
name: m2mApiKeys.name,
userEmail: authUsers.email,
})
.from(m2mApiKeys)
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and(
eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true)
))
.limit(1)
let key = keyRows[0]
if (!key) {
const fallbackValid = apiKey === secrets.M2M_API_KEY
if (!fallbackValid) {
server.log.warn(`[M2M Auth] Ungültiger API-Key bei ${req.url}`)
return reply.status(401).send({ error: "Unauthorized" })
}
// Backward compatibility mode for one global key.
// The caller must provide user/tenant identifiers in headers.
const tenantIdHeader = req.headers["x-tenant-id"]
const userIdHeader = req.headers["x-user-id"]
const tenantId = Number(Array.isArray(tenantIdHeader) ? tenantIdHeader[0] : tenantIdHeader)
const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader
if (!tenantId || !userId) {
return reply.status(401).send({ error: "Missing x-tenant-id or x-user-id for legacy M2M key" })
}
const users = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
if (!users[0]) {
return reply.status(401).send({ error: "Unknown user for legacy M2M key" })
}
req.user = {
user_id: userId,
email: users[0].email,
tenant_id: tenantId
}
} else {
if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) {
return reply.status(401).send({ error: "Expired API key" })
}
req.user = {
user_id: key.userId,
email: key.userEmail,
tenant_id: key.tenantId
}
await server.db
.update(m2mApiKeys)
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
.where(eq(m2mApiKeys.id, key.id))
}
(req as any).m2m = { (req as any).m2m = {
verified: true, verified: true,
type: "internal", type: "internal",
key: apiKey, key: apiKey,
}; };
req.role = "m2m"
req.permissions = []
req.hasPermission = () => false
} catch (err) { } catch (err) {
// @ts-ignore // @ts-ignore
server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err); 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 { import {
authUserRoles, authUserRoles,
authRolePermissions, authRolePermissions,
authUsers,
} from "../../db/schema" } from "../../db/schema"
import { eq, and } from "drizzle-orm" import { eq, and } from "drizzle-orm"
@@ -44,16 +43,6 @@ export default fp(async (server: FastifyInstance) => {
// Payload an Request hängen // Payload an Request hängen
req.user = payload req.user = payload
const [currentUser] = await server.db
.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 // Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
if (!req.user.tenant_id) { if (!req.user.tenant_id) {
return return
@@ -77,13 +66,6 @@ export default fp(async (server: FastifyInstance) => {
.limit(1) .limit(1)
if (roleRows.length === 0) { if (roleRows.length === 0) {
if (req.user.is_admin) {
req.role = ""
req.permissions = []
req.hasPermission = () => false
return
}
return reply return reply
.code(403) .code(403)
.send({ error: "No role assigned for this tenant" }) .send({ error: "No role assigned for this tenant" })
@@ -125,7 +107,6 @@ declare module "fastify" {
user_id: string user_id: string
email: string email: string
tenant_id: number | null tenant_id: number | null
is_admin?: boolean
} }
role: string role: string
permissions: string[] permissions: string[]

View File

@@ -9,15 +9,13 @@ export default fp(async (server: FastifyInstance) => {
"http://localhost:3001", // dein Nuxt-Frontend "http://localhost:3001", // dein Nuxt-Frontend
"http://127.0.0.1:3000", // dein Nuxt-Frontend "http://127.0.0.1:3000", // dein Nuxt-Frontend
"http://192.168.1.227:3001", // dein Nuxt-Frontend "http://192.168.1.227:3001", // dein Nuxt-Frontend
"http://192.168.1.234:3000", // dein Nuxt-Frontend
"http://192.168.1.113:3000", // dein Nuxt-Frontend "http://192.168.1.113:3000", // dein Nuxt-Frontend
"https://beta.fedeo.de", // dein Nuxt-Frontend "https://beta.fedeo.de", // dein Nuxt-Frontend
"https://app.fedeo.de", // dein Nuxt-Frontend "https://app.fedeo.de", // dein Nuxt-Frontend
"capacitor://localhost", // dein Nuxt-Frontend "capacitor://localhost", // dein Nuxt-Frontend
], ],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS","PATCH", methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"], allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"],
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin","Depth", "Overwrite", "Destination", "Lock-Token", "If"],
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
credentials: true, // wichtig, falls du Cookies nutzt credentials: true, // wichtig, falls du Cookies nutzt
}); });

View File

@@ -1,25 +1,31 @@
// src/plugins/db.ts import fp from "fastify-plugin"
import fp from "fastify-plugin"; import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres"
import { NodePgDatabase } from "drizzle-orm/node-postgres"; import * as schema from "../../db/schema"
import * as schema from "../../db/schema"; import {secrets} from "../utils/secrets";
import { db, pool } from "../../db"; // <--- Importiert jetzt die globale Instanz import { Pool } from "pg"
export default fp(async (server, opts) => { export default fp(async (server, opts) => {
// Wir nutzen die db, die wir in src/db/index.ts erstellt haben const pool = new Pool({
server.decorate("db", db); 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 () => { 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" { declare module "fastify" {
interface FastifyInstance { 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> const query = req.query as Record<string, any>
console.log(query)
// Pagination deaktivieren? // Pagination deaktivieren?
const disablePagination = const disablePagination =
query.noPagination === 'true' || query.noPagination === 'true' ||

View File

@@ -1,7 +1,7 @@
// /plugins/services.ts // /plugins/services.ts
import fp from "fastify-plugin"; import fp from "fastify-plugin";
import { bankStatementService } from "../modules/cron/bankstatementsync.service"; 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 { FastifyInstance } from "fastify";
import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices"; import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices";
@@ -9,7 +9,7 @@ declare module "fastify" {
interface FastifyInstance { interface FastifyInstance {
services: { services: {
bankStatements: ReturnType<typeof bankStatementService>; bankStatements: ReturnType<typeof bankStatementService>;
dokuboxSync: ReturnType<typeof syncDokuboxService>; //dokuboxSync: ReturnType<typeof syncDokubox>;
prepareIncomingInvoices: ReturnType<typeof prepareIncomingInvoices>; prepareIncomingInvoices: ReturnType<typeof prepareIncomingInvoices>;
}; };
} }
@@ -18,7 +18,7 @@ declare module "fastify" {
export default fp(async function servicePlugin(server: FastifyInstance) { export default fp(async function servicePlugin(server: FastifyInstance) {
server.decorate("services", { server.decorate("services", {
bankStatements: bankStatementService(server), bankStatements: bankStatementService(server),
dokuboxSync: syncDokuboxService(server), //dokuboxSync: syncDokubox(server),
prepareIncomingInvoices: prepareIncomingInvoices(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) => { export default fp(async (server: FastifyInstance) => {
await server.register(swagger, { await server.register(swagger, {
mode: "dynamic", mode: "dynamic", // wichtig: generiert echtes OpenAPI JSON
openapi: { openapi: {
info: { info: {
title: "FEDEO Backend API", title: "Multi-Tenant API",
description: "OpenAPI specification for the FEDEO backend", description: "API Dokumentation für dein Backend",
version: "1.0.0", version: "1.0.0",
}, },
servers: [{ url: "/" }], servers: [{ url: "http://localhost:3000" }],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT"
}
}
}
}, },
}); });
// @ts-ignore // @ts-ignore
await server.register(swaggerUi, { await server.register(swaggerUi, {
routePrefix: "/docs", routePrefix: "/docs", // UI erreichbar unter http://localhost:3000/docs
swagger: {
info: {
title: "Multi-Tenant API",
version: "1.0.0",
},
},
exposeRoute: true,
}); });
});
// Stable raw spec path
server.get("/openapi.json", async (_req, reply) => {
return reply.send(server.swagger());
});
});

View File

@@ -1,7 +1,5 @@
import { FastifyInstance, FastifyRequest } from "fastify"; import { FastifyInstance, FastifyRequest } from "fastify";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
import { eq } from "drizzle-orm";
import { tenants } from "../../db/schema";
export default fp(async (server: FastifyInstance) => { export default fp(async (server: FastifyInstance) => {
server.addHook("preHandler", async (req, reply) => { server.addHook("preHandler", async (req, reply) => {
@@ -11,12 +9,11 @@ export default fp(async (server: FastifyInstance) => {
return; return;
} }
// Tenant aus DB laden // Tenant aus DB laden
const rows = await server.db const { data: tenant } = await server.supabase
.select() .from("tenants")
.from(tenants) .select("*")
.where(eq(tenants.portalDomain, host)) .eq("portalDomain", host)
.limit(1); .single();
const tenant = rows[0];
if(!tenant) { if(!tenant) {
@@ -41,4 +38,4 @@ declare module "fastify" {
settings?: Record<string, any>; settings?: Record<string, any>;
}; };
} }
} }

View File

@@ -1,761 +1,19 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { FastifyInstance } from "fastify";
import { and, eq, inArray, isNull } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { import {
authTenantUsers, authTenantUsers,
authProfiles,
authRoles,
authUserRoles,
authUsers, authUsers,
filetags,
folders,
tenants, tenants,
} from "../../db/schema"; } from "../../db/schema";
import { generateRandomPassword, hashPassword } from "../utils/password";
export default async function adminRoutes(server: FastifyInstance) { 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 // POST /admin/add-user-to-tenant
// ------------------------------------------------------------- // -------------------------------------------------------------
server.post("/admin/add-user-to-tenant", async (req, reply) => { server.post("/admin/add-user-to-tenant", async (req, reply) => {
try { try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const body = req.body as { const body = req.body as {
user_id: string; user_id: string;
tenant_id: number; tenant_id: number;
@@ -786,10 +44,11 @@ export default async function adminRoutes(server: FastifyInstance) {
await server.db await server.db
.insert(authTenantUsers) .insert(authTenantUsers)
// @ts-ignore
.values({ .values({
user_id: body.user_id, user_id: body.user_id,
tenant_id: body.tenant_id, tenantId: body.tenant_id,
created_by: currentUser.id, role: body.role ?? "member",
}); });
return { success: true, mode }; 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) => { server.get("/admin/user-tenants/:user_id", async (req, reply) => {
try { try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const { user_id } = req.params as { user_id: string }; const { user_id } = req.params as { user_id: string };
if (!user_id) { if (!user_id) {
@@ -838,7 +94,6 @@ export default async function adminRoutes(server: FastifyInstance) {
short: tenants.short, short: tenants.short,
locked: tenants.locked, locked: tenants.locked,
numberRanges: tenants.numberRanges, numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
extraModules: tenants.extraModules, extraModules: tenants.extraModules,
}) })
.from(authTenantUsers) .from(authTenantUsers)

View File

@@ -1,60 +1,11 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import bcrypt from "bcrypt" import bcrypt from "bcrypt"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import jwt from "jsonwebtoken"
import { secrets } from "../../utils/secrets"
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren! import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
export default async function authRoutesAuthenticated(server: FastifyInstance) { 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", { server.post("/auth/password/change", {
schema: { schema: {
tags: ["Auth"], tags: ["Auth"],

View File

@@ -137,7 +137,7 @@ export default async function authRoutes(server: FastifyInstance) {
httpOnly: true, httpOnly: true,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 6, maxAge: 60 * 60 * 3,
}); });
return { token }; return { token };

View File

@@ -31,7 +31,6 @@ export default async function meRoutes(server: FastifyInstance) {
email: authUsers.email, email: authUsers.email,
created_at: authUsers.created_at, created_at: authUsers.created_at,
must_change_password: authUsers.must_change_password, must_change_password: authUsers.must_change_password,
is_admin: authUsers.is_admin,
}) })
.from(authUsers) .from(authUsers)
.where(eq(authUsers.id, userId)) .where(eq(authUsers.id, userId))
@@ -52,12 +51,9 @@ export default async function meRoutes(server: FastifyInstance) {
name: tenants.name, name: tenants.name,
short: tenants.short, short: tenants.short,
locked: tenants.locked, locked: tenants.locked,
features: tenants.features,
extraModules: tenants.extraModules, extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo, businessInfo: tenants.businessInfo,
numberRanges: tenants.numberRanges, numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
dokuboxkey: tenants.dokuboxkey, dokuboxkey: tenants.dokuboxkey,
standardEmailForInvoices: tenants.standardEmailForInvoices, standardEmailForInvoices: tenants.standardEmailForInvoices,
standardPaymentDays: tenants.standardPaymentDays, standardPaymentDays: tenants.standardPaymentDays,

View File

@@ -4,19 +4,10 @@ import dayjs from "dayjs"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { insertHistoryItem } from "../utils/history" 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 { import {
bankrequisitions, bankrequisitions,
bankstatements,
createddocuments,
customers,
entitybankaccounts,
incominginvoices,
statementallocations, statementallocations,
vendors,
} from "../../db/schema" } from "../../db/schema"
import { import {
@@ -26,520 +17,6 @@ import {
export default async function bankingRoutes(server: FastifyInstance) { 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 // 🔐 GoCardLess Token Handling
@@ -694,35 +171,9 @@ export default async function bankingRoutes(server: FastifyInstance) {
const createdRecord = inserted[0] 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, { await insertHistoryItem(server, {
entity: "bankstatements", entity: "bankstatements",
entityId: Number(createdRecord.bankstatement), entityId: createdRecord.id,
action: "created", action: "created",
created_by: req.user.user_id, created_by: req.user.user_id,
tenant_id: req.user.tenant_id, tenant_id: req.user.tenant_id,
@@ -765,7 +216,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
await insertHistoryItem(server, { await insertHistoryItem(server, {
entity: "bankstatements", entity: "bankstatements",
entityId: Number(old.bankstatement), entityId: id,
action: "deleted", action: "deleted",
created_by: req.user.user_id, created_by: req.user.user_id,
tenant_id: req.user.tenant_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 { FastifyInstance } from "fastify";
import { and, desc, eq } from "drizzle-orm"; import {and, desc, eq} from "drizzle-orm";
import { authProfiles, devices, stafftimeevents } from "../../../db/schema"; import {authProfiles, devices, stafftimeevents} from "../../../db/schema";
export default async function devicesRFIDRoutes(server: FastifyInstance) { export default async function devicesRFIDRoutes(server: FastifyInstance) {
server.post( server.post(
"/rfid/createevent/:terminal_id", "/rfid/createevent/:terminal_id",
async (req, reply) => { async (req, reply) => {
try { 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`); 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 const device = await server.db
.select() .select()
.from(devices) .from(devices)
.where(eq(devices.externalId, terminal_id)) .where(
eq(devices.externalId, terminal_id)
)
.limit(1) .limit(1)
.then(rows => rows[0]); .then(rows => rows[0]);
if (!device) { if(!device) {
console.log(`Device ${terminal_id} not found`); 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 const profile = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
@@ -46,56 +44,55 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
.limit(1) .limit(1)
.then(rows => rows[0]); .then(rows => rows[0]);
if (!profile) { if(!profile) {
console.log(`Profile for Token ${rfid_id} not found`); 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 const lastEvent = await server.db
.select() .select()
.from(stafftimeevents) .from(stafftimeevents)
.where(eq(stafftimeevents.user_id, profile.user_id)) .where(
.orderBy(desc(stafftimeevents.eventtime)) eq(stafftimeevents.user_id, profile.user_id)
)
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
.limit(1) .limit(1)
.then(rows => rows[0]); .then(rows => rows[0]);
// 5. Zeitstempel Logik (WICHTIG!) console.log(lastEvent)
// Der ESP32 sendet Unix-Timestamp in SEKUNDEN. JS braucht MILLISEKUNDEN.
// Wenn kein Timestamp kommt (0 oder undefined), nehmen wir JETZT.
const actualEventTime = (timestamp && timestamp > 0)
? new Date(timestamp * 1000)
: new Date();
// 6. Event Typ bestimmen (Toggle Logik)
// Falls noch nie gestempelt wurde (lastEvent undefined), fangen wir mit start an.
const nextEventType = (lastEvent?.eventtype === "work_start")
? "work_end"
: "work_start";
const dataToInsert = { const dataToInsert = {
tenant_id: device.tenant, tenant_id: device.tenant,
user_id: profile.user_id, user_id: profile.user_id,
actortype: "system", actortype: "system",
eventtime: actualEventTime, // Hier nutzen wir die berechnete Zeit eventtime: new Date(),
eventtype: nextEventType, eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
source: "TERMINAL" // Habe ich von WEB auf TERMINAL geändert (optional) source: "WEB"
}; }
console.log(`New Event for ${profile.user_id}: ${nextEventType} @ ${actualEventTime.toISOString()}`); console.log(dataToInsert)
const [created] = await server.db const [created] = await server.db
.insert(stafftimeevents) .insert(stafftimeevents)
//@ts-ignore //@ts-ignore
.values(dataToInsert) .values(dataToInsert)
.returning(); .returning()
return created;
return created
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err)
return reply.code(400).send({ error: err.message }); return reply.code(400).send({ error: err.message })
} }
console.log(req.body)
return
} }
); );
} }

View File

@@ -1,4 +1,6 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import jwt from "jsonwebtoken";
import {insertHistoryItem} from "../utils/history";
import {buildExportZip} from "../utils/export/datev"; import {buildExportZip} from "../utils/export/datev";
import {s3} from "../utils/s3"; import {s3} from "../utils/s3";
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3" import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
@@ -7,8 +9,6 @@ import dayjs from "dayjs";
import {randomUUID} from "node:crypto"; import {randomUUID} from "node:crypto";
import {secrets} from "../utils/secrets"; import {secrets} from "../utils/secrets";
import {createSEPAExport} from "../utils/export/sepa"; 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) => { const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
try { try {
@@ -45,21 +45,25 @@ const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDat
console.log(url) console.log(url)
// 5) In Haupt-DB speichern // 5) In Supabase-DB speichern
const inserted = await server.db const { data, error } = await server.supabase
.insert(generatedexports) .from("exports")
.values({ .insert([
tenantId: req.user.tenant_id, {
startDate: new Date(startDate), tenant_id: req.user.tenant_id,
endDate: new Date(endDate), start_date: startDate,
validUntil: dayjs().add(24, "hours").toDate(), end_date: endDate,
filePath: fileKey, valid_until: dayjs().add(24,"hours").toISOString(),
url, file_path: fileKey,
type: "datev", url: url,
}) created_at: new Date().toISOString(),
.returning() },
])
.select()
.single()
console.log(inserted[0]) console.log(data)
console.log(error)
} catch (error) { } catch (error) {
console.log(error) console.log(error)
} }
@@ -116,22 +120,9 @@ export default async function exportRoutes(server: FastifyInstance) {
//List Exports Available for Download //List Exports Available for Download
server.get("/exports", async (req,reply) => { server.get("/exports", async (req,reply) => {
const data = await server.db const {data,error} = await server.supabase.from("exports").select().eq("tenant_id",req.user.tenant_id)
.select({
id: generatedexports.id,
created_at: generatedexports.createdAt,
tenant_id: generatedexports.tenantId,
start_date: generatedexports.startDate,
end_date: generatedexports.endDate,
valid_until: generatedexports.validUntil,
type: generatedexports.type,
url: generatedexports.url,
file_path: generatedexports.filePath,
})
.from(generatedexports)
.where(eq(generatedexports.tenantId, req.user.tenant_id))
console.log(data) console.log(data,error)
reply.send(data) reply.send(data)
}) })
@@ -140,4 +131,4 @@ export default async function exportRoutes(server: FastifyInstance) {
} }

View File

@@ -2,12 +2,12 @@ import { FastifyInstance } from "fastify"
import multipart from "@fastify/multipart" import multipart from "@fastify/multipart"
import { s3 } from "../utils/s3" import { s3 } from "../utils/s3"
import { import {
GetObjectCommand GetObjectCommand,
PutObjectCommand
} from "@aws-sdk/client-s3" } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import archiver from "archiver" import archiver from "archiver"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files"
import { eq, inArray } from "drizzle-orm" import { eq, inArray } from "drizzle-orm"
import { import {
@@ -40,28 +40,39 @@ export default async function fileRoutes(server: FastifyInstance) {
const fileBuffer = await data.toBuffer() const fileBuffer = await data.toBuffer()
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {} const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
const { folder = null, type = null, ...otherMeta } = meta
const created = await saveFile( // 1⃣ DB-Eintrag erzeugen
server, const inserted = await server.db
tenantId, .insert(files)
null, .values({ tenant: tenantId })
{ .returning()
filename: data.filename,
content: fileBuffer,
contentType: data.mimetype
},
folder,
type,
otherMeta
)
const created = inserted[0]
if (!created) throw new Error("Could not create DB entry") 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 { return {
id: created.id, id: created.id,
filename: created.filename, filename: data.filename,
path: created.key path: fileKey
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -237,7 +248,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// MULTIPLE PRESIGNED URLs // MULTIPLE PRESIGNED URLs
// ------------------------------------------------- // -------------------------------------------------
if (!ids || !Array.isArray(ids) || ids.length === 0) { if (!ids || !Array.isArray(ids) || ids.length === 0) {
return { files: [] } return reply.code(400).send({ error: "No ids provided" })
} }
const rows = await server.db const rows = await server.db

View File

@@ -1,11 +1,6 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf"; import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions"; //import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import dayjs from "dayjs"; import dayjs from "dayjs";
//import { ready as zplReady } from 'zpl-renderer-js' //import { ready as zplReady } from 'zpl-renderer-js'
//import { renderZPL } from "zpl-image"; //import { renderZPL } from "zpl-image";
@@ -18,12 +13,10 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
import duration from "dayjs/plugin/duration.js"; import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js"; import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service"; import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys, files} from "../../db/schema"; import {citys} from "../../db/schema";
import {and, eq, isNull, not} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {useNextNumberRangeNumber} from "../utils/functions";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; 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(customParseFormat)
dayjs.extend(isoWeek) dayjs.extend(isoWeek)
dayjs.extend(isBetween) dayjs.extend(isBetween)
@@ -32,40 +25,7 @@ dayjs.extend(isSameOrBefore)
dayjs.extend(duration) dayjs.extend(duration)
dayjs.extend(timezone) 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) { 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) => { server.post("/functions/pdf/:type", async (req, reply) => {
const body = req.body as { const body = req.body as {
data: any data: any
@@ -140,25 +100,31 @@ export default async function functionRoutes(server: FastifyInstance) {
server.get('/functions/check-zip/:zip', async (req, reply) => { server.get('/functions/check-zip/:zip', async (req, reply) => {
const { zip } = req.params as { zip: string } const { zip } = req.params as { zip: string }
const normalizedZip = String(zip || "").replace(/\D/g, "")
if (normalizedZip.length !== 5) { if (!zip) {
return reply.code(400).send({ error: 'ZIP must contain exactly 5 digits' }) return reply.code(400).send({ error: 'ZIP is required' })
} }
try { 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() .select()
.from(citys) .eq('zip', zip)
.where(eq(citys.zip, Number(normalizedZip))) .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' }) return reply.code(404).send({ error: 'ZIP not found' })
} }
const city = data[0]
//districtMap //districtMap
const bundeslaender = [ const bundeslaender = [
{ code: 'DE-BW', name: 'Baden-Württemberg' }, { code: 'DE-BW', name: 'Baden-Württemberg' },
@@ -182,8 +148,9 @@ export default async function functionRoutes(server: FastifyInstance) {
return reply.send({ return reply.send({
...city, ...data,
state_code: bundeslaender.find(i => i.name === city.countryName)?.code || null //@ts-ignore
state_code: bundeslaender.find(i => i.name === data.countryName)
}) })
} catch (err) { } catch (err) {
console.log(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) => { server.post('/functions/serial/start', async (req, reply) => {
console.log(req.body) console.log(req.body)
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number} 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) 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 /*server.post('/print/zpl/preview', async (req, reply) => {
.select() const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}
.from(files)
.where(
and(
eq(files.tenant, tenantId),
eq(files.archived, false),
not(isNull(files.path)),
isNull(files.extractedText)
)
)
let processed = 0 console.log(widthMm,heightMm,dpmm)
let withText = 0
let errors = 0
for (const file of pendingFiles) { if (!zpl) {
try { return reply.code(400).send({ error: 'Missing ZPL string' })
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)
}
} }
return { try {
pending: pendingFiles.length, // 1⃣ Renderer initialisieren
processed, const { api } = await zplReady
withText,
errors // 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) => { 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 { try {
const base64 = await generateLabel(context,width,height) const base64 = await generateLabel(context,width,heigth)
return { return {
encoded: await encodeBase64ToNiimbot(base64, 'top'), encoded: await encodeBase64ToNiimbot(base64, 'top'),
base64: base64 base64: base64
} }
} catch (err) { } catch (err) {
console.error('[Label Render Error]', err) console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render label' }) 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) { export default async function routes(server: FastifyInstance) {
server.get("/ping", async () => { server.get("/ping", async () => {
// Testquery gegen DB // Testquery gegen DB
const result = await server.db.execute("SELECT NOW()"); const { data, error } = await server.supabase.from("tenants").select("id").limit(1);
return { return {
status: "ok", 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 { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js' import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js' import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
import { findCustomerOrContactByEmailOrDomain } from "../utils/helpers"; import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers";
import { eq } from "drizzle-orm"; import {useNextNumberRangeNumber} from "../utils/functions";
import { helpdesk_conversations, helpdesk_messages } from "../../db/schema";
// ------------------------------------------------------------- // -------------------------------------------------------------
// 📧 Interne M2M-Route für eingehende E-Mails // 📧 Interne M2M-Route für eingehende E-Mails
@@ -53,12 +52,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
// 3⃣ Konversation anhand In-Reply-To suchen // 3⃣ Konversation anhand In-Reply-To suchen
let conversationId: string | null = null let conversationId: string | null = null
if (in_reply_to) { if (in_reply_to) {
const msg = await server.db const { data: msg } = await server.supabase
.select({ conversationId: helpdesk_messages.conversationId }) .from('helpdesk_messages')
.from(helpdesk_messages) .select('conversation_id')
.where(eq(helpdesk_messages.externalMessageId, in_reply_to)) .eq('external_message_id', in_reply_to)
.limit(1) .maybeSingle()
conversationId = msg[0]?.conversationId || null conversationId = msg?.conversation_id || null
} }
// 4⃣ Neue Konversation anlegen falls keine existiert // 4⃣ Neue Konversation anlegen falls keine existiert
@@ -74,12 +73,12 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
}) })
conversationId = conversation.id conversationId = conversation.id
} else { } else {
const rows = await server.db const { data } = await server.supabase
.select() .from('helpdesk_conversations')
.from(helpdesk_conversations) .select('*')
.where(eq(helpdesk_conversations.id, conversationId)) .eq('id', conversationId)
.limit(1) .single()
conversation = rows[0] conversation = data
} }
// 5⃣ Nachricht speichern // 5⃣ Nachricht speichern
@@ -97,7 +96,7 @@ const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
return res.status(201).send({ return res.status(201).send({
success: true, success: true,
conversation_id: conversationId, 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 { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js' import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.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) => { const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
// Öffentliche POST-Route // Öffentliche POST-Route
@@ -24,18 +85,17 @@ const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
} }
// 1⃣ Kanalinstanz anhand des Tokens ermitteln // 1⃣ Kanalinstanz anhand des Tokens ermitteln
const channels = await server.db const { data: channel, error: channelError } = await server.supabase
.select() .from('helpdesk_channel_instances')
.from(helpdesk_channel_instances) .select('*')
.where(eq(helpdesk_channel_instances.publicToken, public_token)) .eq('public_token', public_token)
.limit(1) .single()
const channel = channels[0]
if (!channel) { if (channelError || !channel) {
return res.status(404).send({ error: 'Invalid channel token' }) 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 const channel_instance_id = channel.id
// @ts-ignore // @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 { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
import {decrypt, encrypt} from "../utils/crypt"; import {decrypt, encrypt} from "../utils/crypt";
import nodemailer from "nodemailer" 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) => { const helpdeskRoutes: FastifyPluginAsync = async (server) => {
// 📩 1. Liste aller Konversationen // 📩 1. Liste aller Konversationen
@@ -65,30 +58,15 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const tenant_id = req.user?.tenant_id const tenant_id = req.user?.tenant_id
const {id: conversation_id} = req.params as {id: string} const {id: conversation_id} = req.params as {id: string}
const rows = await server.db const { data, error } = await server.supabase
.select({ .from('helpdesk_conversations')
conversation: helpdesk_conversations, .select('*, helpdesk_contacts(*)')
contact: helpdesk_contacts .eq('tenant_id', tenant_id)
}) .eq('id', conversation_id)
.from(helpdesk_conversations) .single()
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId))
.where(eq(helpdesk_conversations.id, conversation_id))
const data = rows[0] if (error) return res.status(404).send({ error: 'Conversation not found' })
if (!data || data.conversation.tenantId !== tenant_id) return res.status(404).send({ error: 'Conversation not found' }) return res.send(data)
return res.send({
...data.conversation,
channel_instance_id: data.conversation.channelInstanceId,
contact_id: data.conversation.contactId,
contact_person_id: data.conversation.contactPersonId,
created_at: data.conversation.createdAt,
customer_id: data.conversation.customerId,
last_message_at: data.conversation.lastMessageAt,
tenant_id: data.conversation.tenantId,
ticket_number: data.conversation.ticketNumber,
helpdesk_contacts: data.contact,
})
}) })
// 🔄 4. Konversation Status ändern // 🔄 4. Konversation Status ändern
@@ -203,39 +181,36 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass) safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
} }
const inserted = await server.db // Speichern in Supabase
.insert(helpdesk_channel_instances) const { data, error } = await server.supabase
.values({ .from("helpdesk_channel_instances")
tenantId: tenant_id, .insert({
typeId: type_id, tenant_id,
type_id,
name, name,
config: safeConfig, config: safeConfig,
isActive: is_active, is_active,
}) })
.returning() .select()
.single()
const data = inserted[0] if (error) throw error
if (!data) throw new Error("Konnte Channel nicht erstellen")
const responseConfig: any = data.config
// sensible Felder aus Response entfernen // sensible Felder aus Response entfernen
if (responseConfig?.imap) { if (data.config?.imap) {
delete responseConfig.imap.host delete data.config.imap.host
delete responseConfig.imap.user delete data.config.imap.user
delete responseConfig.imap.pass delete data.config.imap.pass
} }
if (responseConfig?.smtp) { if (data.config?.smtp) {
delete responseConfig.smtp.host delete data.config.smtp.host
delete responseConfig.smtp.user delete data.config.smtp.user
delete responseConfig.smtp.pass delete data.config.smtp.pass
} }
reply.send({ reply.send({
message: "E-Mail-Channel erfolgreich erstellt", message: "E-Mail-Channel erfolgreich erstellt",
channel: { channel: data,
...data,
config: responseConfig
},
}) })
} catch (err) { } catch (err) {
console.error("Fehler bei Channel-Erstellung:", err) console.error("Fehler bei Channel-Erstellung:", err)
@@ -259,29 +234,29 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const { text } = req.body as { text: string } const { text } = req.body as { text: string }
// 🔹 Konversation inkl. Channel + Kontakt laden // 🔹 Konversation inkl. Channel + Kontakt laden
const rows = await server.db const { data: conv, error: convErr } = await server.supabase
.select({ .from("helpdesk_conversations")
conversation: helpdesk_conversations, .select(`
contact: helpdesk_contacts, id,
channel: helpdesk_channel_instances, tenant_id,
}) subject,
.from(helpdesk_conversations) channel_instance_id,
.leftJoin(helpdesk_contacts, eq(helpdesk_contacts.id, helpdesk_conversations.contactId)) helpdesk_contacts(email),
.leftJoin(helpdesk_channel_instances, eq(helpdesk_channel_instances.id, helpdesk_conversations.channelInstanceId)) helpdesk_channel_instances(config, name),
.where(eq(helpdesk_conversations.id, conversationId)) ticket_number
.limit(1) `)
.eq("id", conversationId)
const conv = rows[0] .single()
console.log(conv) console.log(conv)
if (!conv) { if (convErr || !conv) {
reply.status(404).send({ error: "Konversation nicht gefunden" }) reply.status(404).send({ error: "Konversation nicht gefunden" })
return return
} }
const contact = conv.contact as unknown as {email: string} const contact = conv.helpdesk_contacts as unknown as {email: string}
const channel = conv.channel as unknown as {name: string, config: any} const channel = conv.helpdesk_channel_instances as unknown as {name: string}
console.log(contact) console.log(contact)
if (!contact?.email) { if (!contact?.email) {
@@ -313,7 +288,7 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const mailOptions = { const mailOptions = {
from: `"${channel?.name}" <${user}>`, from: `"${channel?.name}" <${user}>`,
to: contact.email, 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, text,
} }
@@ -321,22 +296,24 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`) console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
// 💾 Nachricht speichern // 💾 Nachricht speichern
await server.db const { error: insertErr } = await server.supabase
.insert(helpdesk_messages) .from("helpdesk_messages")
.values({ .insert({
tenantId: conv.conversation.tenantId, tenant_id: conv.tenant_id,
conversationId: conversationId, conversation_id: conversationId,
direction: "outgoing", direction: "outgoing",
payload: { type: "text", text }, payload: { type: "text", text },
externalMessageId: info.messageId, external_message_id: info.messageId,
receivedAt: new Date(), received_at: new Date().toISOString(),
}) })
if (insertErr) throw insertErr
// 🔁 Konversation aktualisieren // 🔁 Konversation aktualisieren
await server.db await server.supabase
.update(helpdesk_conversations) .from("helpdesk_conversations")
.set({ lastMessageAt: new Date() }) .update({ last_message_at: new Date().toISOString() })
.where(eq(helpdesk_conversations.id, conversationId)) .eq("id", conversationId)
reply.send({ reply.send({
message: "E-Mail erfolgreich gesendet", message: "E-Mail erfolgreich gesendet",

View File

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

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 // routes/notifications.routes.ts
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { NotificationService, UserDirectory } from '../modules/notification.service'; 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 // Beispiel: E-Mail aus eigener User-Tabelle laden
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => { const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
const rows = await server.db const { data, error } = await server.supabase
.select({ email: authUsers.email }) .from('auth_users')
.from(authUsers) .select('email')
.where(eq(authUsers.id, userId)) .eq('id', userId)
.limit(1) .maybeSingle();
const data = rows[0] if (error || !data) return null;
if (!data) return null;
return { email: data.email }; return { email: data.email };
}; };
export default async function notificationsRoutes(server: FastifyInstance) { export default async function notificationsRoutes(server: FastifyInstance) {
// wichtig: server.supabase ist über app verfügbar
const svc = new NotificationService(server, getUserDirectory); const svc = new NotificationService(server, getUserDirectory);
server.post('/notifications/trigger', async (req, reply) => { server.post('/notifications/trigger', async (req, reply) => {

View File

@@ -1,19 +1,40 @@
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'; import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import { publicLinkService } from '../../modules/publiclinks.service'; import { publicLinkService } from '../../modules/publiclinks.service';
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) { export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
server.get("/workflows/context/:token", async (req, reply) => { server.get("/workflows/context/:token", async (req, reply) => {
const { token } = req.params as { token: string }; const { token } = req.params as { token: string };
// Wir lesen die PIN aus dem Header (Best Practice für Security)
const pin = req.headers['x-public-pin'] as string | undefined; const pin = req.headers['x-public-pin'] as string | undefined;
try { try {
const context = await publicLinkService.getLinkContext(server, token, pin); const context = await publicLinkService.getLinkContext(server, token, pin);
return reply.send(context); return reply.send(context);
} catch (error: any) { } catch (error: any) {
if (error.message === "Link_NotFound") return reply.code(404).send({ error: "Link nicht gefunden" }); // Spezifische Fehlercodes für das Frontend
if (error.message === "Pin_Required") return reply.code(401).send({ error: "PIN erforderlich", requirePin: true }); if (error.message === "Link_NotFound") {
if (error.message === "Pin_Invalid") return reply.code(403).send({ error: "PIN falsch", requirePin: true }); 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); server.log.error(error);
return reply.code(500).send({ error: "Interner Server Fehler" }); 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) => { server.post("/workflows/submit/:token", async (req, reply) => {
const { token } = req.params as { token: string }; const { token } = req.params as { token: string };
// PIN sicher aus dem Header lesen
const pin = req.headers['x-public-pin'] as string | undefined; 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 { try {
const quantity = parseFloat(body.quantity) || 0; // Service aufrufen (führt die 3 Schritte aus: Lieferschein -> Zeit -> History)
// Wir nutzen das vom User gewählte deliveryDate
// Falls kein Datum geschickt wurde, Fallback auf Heute
const baseDate = body.deliveryDate ? dayjs(body.deliveryDate) : dayjs();
const payload = {
...body,
// Wir mappen das deliveryDate auf die Zeitstempel
// Start ist z.B. 08:00 Uhr am gewählten Tag, Ende ist Start + Menge
startDate: baseDate.hour(8).minute(0).toDate(),
endDate: baseDate.hour(8).add(quantity, 'hour').toDate(),
deliveryDate: baseDate.format('YYYY-MM-DD')
};
const result = await publicLinkService.submitFormData(server, token, payload, pin); const result = await publicLinkService.submitFormData(server, token, payload, pin);
// 201 Created zurückgeben
return reply.code(201).send(result); return reply.code(201).send(result);
} catch (error: any) { } catch (error: any) {
server.log.error(error); console.log(error);
return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message });
// 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 { FastifyInstance } from "fastify"
import { asc, desc, eq } from "drizzle-orm" import { asc, desc } from "drizzle-orm"
import { sortData } from "../utils/sort" import { sortData } from "../utils/sort"
// Schema imports // Schema imports
import { accounts, units, countrys, tenants } from "../../db/schema" import { accounts, units,countrys } from "../../db/schema"
const TABLE_MAP: Record<string, any> = { const TABLE_MAP: Record<string, any> = {
accounts, 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! // Drizzle kann kein dynamisches Select aus String!
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend // 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) let query = server.db.select().from(table)
// --------------------------------------- // ---------------------------------------

View File

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

View File

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

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