Compare commits
59 Commits
1c39b69513
...
v3.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 20091143d2 | |||
| 8e1505023b | |||
| 60553bbba8 | |||
| 96295ee4f0 | |||
| f708d0a2b2 | |||
| 7164989da9 | |||
| cbec5b058d | |||
| 847a616285 | |||
| fdf1d42a3d | |||
| 7e9f4409f6 | |||
| 0ff74cdcdf | |||
| f51f2b13a1 | |||
| 2e63af1aa2 | |||
| 74c0cf40b3 | |||
| 5ebd2d508c | |||
| f6996c1d7b | |||
| 9128fbb165 | |||
| bb94e73229 | |||
| 8473221c13 | |||
| 68c0a224e5 | |||
| 1329f164d8 | |||
| 92859f3ccf | |||
| dd37622bb6 | |||
| cfef07b96d | |||
| 342ad9764e | |||
| 58fc252110 | |||
| 0c430e993e | |||
| a50ff256ee | |||
| ebbd1e0cd0 | |||
| 2ec162c0e7 | |||
| d0bc8a5e0f | |||
| a6f55b61e4 | |||
| c383a291e1 | |||
| 0eadf42973 | |||
| 616bb5d7f3 | |||
| 6297480b89 | |||
| 48199b0ed7 | |||
| bb18a974dd | |||
| 4e05906556 | |||
| df2308d407 | |||
| 5b6579423b | |||
| b3dd46195d | |||
| 54294ce7e9 | |||
| 5141a1f3ac | |||
| 718ecefd46 | |||
| e90cf819e7 | |||
| 15e21a97e2 | |||
| 358e40d749 | |||
| 47bd8e80e4 | |||
| 92c354c1b9 | |||
| 5e1631e2d3 | |||
| e7554fa2cc | |||
| 7c1fabf58a | |||
| 1203b6cbd1 | |||
| 525f2906fb | |||
| b105382abf | |||
| b1cdec7d17 | |||
| f1d512b2e5 | |||
| db21b43120 |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
**/node_modules
|
||||
**/.nuxt
|
||||
**/.output
|
||||
**/dist
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
.release
|
||||
gmail-puett
|
||||
49
.env.example
49
.env.example
@@ -1,4 +1,6 @@
|
||||
# FEDEO Selfhosting
|
||||
# Feste Release-Version, z. B. 3.0.0. dev behält den bisherigen Entwicklungskanal bei.
|
||||
FEDEO_VERSION=dev
|
||||
DOMAIN=app.example.com
|
||||
CONTACT_EMAIL=admin@deine-domain.de
|
||||
|
||||
@@ -117,50 +119,3 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
||||
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
||||
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
||||
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
||||
FEDEO_BOOTSTRAP_MATRIX=true
|
||||
|
||||
# FEDEO Matrix-Kommunikation
|
||||
#
|
||||
# Diese Werte werden von docker-compose.selfhost.yml für den integrierten
|
||||
# Matrix-Stack gelesen. Für produktive Systeme müssen alle Geheimnisse ersetzt
|
||||
# werden.
|
||||
|
||||
MATRIX_SERVER_NAME=app.example.com
|
||||
|
||||
MATRIX_POSTGRES_DB=synapse
|
||||
MATRIX_POSTGRES_USER=synapse
|
||||
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
|
||||
|
||||
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
|
||||
|
||||
LIVEKIT_KEY=fedeo-livekit
|
||||
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
|
||||
|
||||
# Backend-Integration im Selfhost-Stack
|
||||
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
|
||||
MATRIX_RTC_HOST=app.example.com
|
||||
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
|
||||
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
|
||||
MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret
|
||||
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
|
||||
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
|
||||
|
||||
# Lokale Matrix-Entwicklung
|
||||
MATRIX_DEV_SYNAPSE_PORT=8008
|
||||
MATRIX_DEV_ELEMENT_PORT=8080
|
||||
MATRIX_DEV_RTC_JWT_PORT=8081
|
||||
MATRIX_DEV_LIVEKIT_PORT=7880
|
||||
MATRIX_DEV_LIVEKIT_TCP_PORT=7881
|
||||
MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000
|
||||
MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100
|
||||
MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1
|
||||
MATRIX_DEV_TURN_PORT=3478
|
||||
MATRIX_DEV_TURN_MIN_PORT=49160
|
||||
MATRIX_DEV_TURN_MAX_PORT=49200
|
||||
|
||||
# Lokale Backend-Integration gegen den Matrix-Entwicklungsstack
|
||||
# MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
# MATRIX_RTC_JWT_URL=http://localhost:8081
|
||||
# MATRIX_LIVEKIT_URL=ws://localhost:7880
|
||||
# MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
|
||||
# NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080
|
||||
|
||||
@@ -2,37 +2,18 @@
|
||||
name: 🐛 Bug Report
|
||||
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
labels: Problem
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Beschreibung**
|
||||
Eine klare und prägnante Beschreibung des Fehlers.
|
||||
|
||||
|
||||
**Reproduktion**
|
||||
Schritte, um den Fehler zu reproduzieren:
|
||||
|
||||
Entweder:
|
||||
1. Gehe zu '...'
|
||||
2. Klicke auf '...'
|
||||
3. Scrolle runter zu '...'
|
||||
4. Siehe Fehler
|
||||
|
||||
Oder Link zur Seite
|
||||
|
||||
**Erwartetes Verhalten**
|
||||
Eine klare Beschreibung dessen, was du erwartet hast.
|
||||
|
||||
**Screenshots**
|
||||
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
|
||||
|
||||
|
||||
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
|
||||
|
||||
**Umgebung:**
|
||||
- Betriebssystem: [z.B. Windows, macOS, Linux]
|
||||
- Browser / Version (falls relevant): [z.B. Chrome 120]
|
||||
- Projekt-Version: [z.B. v1.0.2]
|
||||
|
||||
**Zusätzlicher Kontext**
|
||||
Füge hier alle anderen Informationen zum Problem hinzu.
|
||||
@@ -2,19 +2,16 @@
|
||||
name: ✨ Feature Request
|
||||
about: Schlage eine Idee für dieses Projekt vor.
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
labels: Funktionswunsch
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
||||
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
|
||||
|
||||
|
||||
**Lösungsvorschlag**
|
||||
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
|
||||
|
||||
|
||||
**Alternativen**
|
||||
Hast du über alternative Lösungen oder Workarounds nachgedacht?
|
||||
|
||||
**Zusätzlicher Kontext**
|
||||
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.
|
||||
@@ -1,233 +1,57 @@
|
||||
name: Build and Push Docker Images
|
||||
run-name: Build Backend, Frontend, Website & Docs by @${{ github.actor }}
|
||||
name: Build and Release FEDEO
|
||||
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
# Passe dies an deine Registry an.
|
||||
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
|
||||
# Beispiel: gitea.deine-domain.de
|
||||
REGISTRY_HOST: git.federspiel.tech
|
||||
# Der Name des Repos (z.B. user/repo).
|
||||
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
|
||||
IMAGE_NAME: flfeders/fedeo
|
||||
ACTOR: flfeders
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# verify-docs-sync:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Check out repository code
|
||||
# uses: actions/checkout@v3
|
||||
#
|
||||
# - name: Prüfe Node-Version
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: 20
|
||||
#
|
||||
# - name: Synchronisiere Funktionsdokumentation
|
||||
# run: node docs/scripts/sync-funktionsdoku.mjs
|
||||
#
|
||||
# - name: Breche ab, wenn Doku nicht aktuell committed ist
|
||||
# run: |
|
||||
# if [ -n "$(git status --porcelain docs/)" ]; then
|
||||
# echo "Die generierte Dokumentation ist nicht aktuell."
|
||||
# echo "Bitte lokal ausführen: node docs/scripts/sync-funktionsdoku.mjs"
|
||||
# echo "Danach die Änderungen committen."
|
||||
# git status --short docs/
|
||||
# exit 1
|
||||
# fi
|
||||
|
||||
build-backend:
|
||||
#needs: verify-docs-sync
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
# Gitea stellt secrets.GITHUB_TOKEN automatisch bereit (wie GitLab CI_JOB_TOKEN)
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v4
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/backend
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Backend
|
||||
uses: docker/build-push-action@v4
|
||||
node-version: '22'
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
context: ./backend # Hier wird der Ordner gewechselt (wie 'cd backend')
|
||||
push: true
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
python-version: '3.12'
|
||||
- name: Test releases and selfhost updates
|
||||
run: npm test
|
||||
- name: Check Conventional Commits
|
||||
run: npm run commits:check
|
||||
|
||||
build-frontend:
|
||||
#needs: verify-docs-sync
|
||||
build-and-release:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v4
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
# dev uses the normal read-only checkout; only main needs a release writer.
|
||||
token: ${{ github.ref == 'refs/heads/main' && (secrets.RELEASE_TOKEN || vars.RELEASE_TOKEN || vars.CI_TOKEN || secrets.CI_TOKEN) || github.token }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/frontend
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Frontend
|
||||
uses: docker/build-push-action@v4
|
||||
node-version: '22'
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
|
||||
build-docs:
|
||||
needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docs
|
||||
id: meta-docs
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/docs
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Docs
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./docs-site/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-docs.outputs.tags }}
|
||||
labels: ${{ steps.meta-docs.outputs.labels }}
|
||||
|
||||
build-website:
|
||||
#needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Website
|
||||
id: meta-website
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/website
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Website
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./website
|
||||
push: true
|
||||
tags: ${{ steps.meta-website.outputs.tags }}
|
||||
labels: ${{ steps.meta-website.outputs.labels }}
|
||||
|
||||
build-central-services-api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Central Services API
|
||||
id: meta-central-api
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/central-services-api
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Central Services API
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./push-server
|
||||
file: ./push-server/apps/api/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-central-api.outputs.tags }}
|
||||
labels: ${{ steps.meta-central-api.outputs.labels }}
|
||||
|
||||
build-central-services-admin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Central Services Admin
|
||||
id: meta-central-admin
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/central-services-admin
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Central Services Admin
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./push-server
|
||||
file: ./push-server/apps/admin/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-central-admin.outputs.tags }}
|
||||
labels: ${{ steps.meta-central-admin.outputs.labels }}
|
||||
registry: git.federspiel.tech
|
||||
username: flfeders
|
||||
# Same credential source as the existing image-build workflows.
|
||||
password: ${{ vars.CI_TOKEN || secrets.CI_TOKEN }}
|
||||
- name: Build all images and publish main releases
|
||||
# main uses a Git ref lock. dev only reads Git and writes to the registry.
|
||||
env:
|
||||
CI: 'true'
|
||||
FEDEO_REF: ${{ github.ref }}
|
||||
FEDEO_SHA: ${{ github.sha }}
|
||||
FEDEO_EVENT_NAME: ${{ github.event_name }}
|
||||
GITEA_TOKEN: ${{ github.ref == 'refs/heads/main' && (secrets.RELEASE_TOKEN || vars.RELEASE_TOKEN || vars.CI_TOKEN || secrets.CI_TOKEN) || '' }}
|
||||
run: npm run release:ci
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
/.release/
|
||||
__pycache__/
|
||||
|
||||
# Lokale Runtime-Daten und generierte Konfigurationen
|
||||
matrix/postgres/
|
||||
|
||||
21
AGENTS.md
Normal file
21
AGENTS.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Projektanweisungen für FEDEO
|
||||
|
||||
## Git und Commit-Nachrichten
|
||||
|
||||
- Neue Projektdateien immer gezielt mit `git add` aufnehmen.
|
||||
- Nach einem abgeschlossenen Arbeitsschritt die eigenen Änderungen committen. Bereits vorhandene Änderungen anderer Aufgaben nicht mitcommitten oder zurücksetzen.
|
||||
- Jede selbst verfasste Commit-Nachricht muss Conventional Commits verwenden: `type(scope): Beschreibung`. Der Scope ist optional.
|
||||
- `fix:` für Fehlerkorrekturen (Patch), `feat:` für neue Funktionen (Minor), `perf:` für Leistungsverbesserungen (Patch).
|
||||
- `docs:`, `test:`, `refactor:`, `build:`, `ci:`, `chore:`, `style:` und `revert:` passend verwenden. Reine Wartungsänderungen lösen standardmäßig kein Release aus; Reverts können einen Patch auslösen.
|
||||
- Inkompatible Änderungen mit `!` im Betreff und einem Footer `BREAKING CHANGE: Beschreibung und Migrationshinweis` markieren (Major). Diese Kennzeichnung niemals weglassen, um einen Versionssprung zu vermeiden.
|
||||
- Beispiele: `fix(accounting): validate invoice totals`, `feat(stock): add inventory transfers`, `feat(api)!: replace legacy authentication`.
|
||||
- Auch selbst verfasste Squash- und Merge-Betreffzeilen entsprechend benennen. Die Release-Auswertung liest die ursprünglichen Commits und überspringt echte Merge-Commits.
|
||||
|
||||
## Releases
|
||||
|
||||
- `dev` ist der Entwicklungszweig. Ein Push auf `main` ist der vom Benutzer gewählte Release-Auslöser; der Gitea-Workflow prüft, baut und veröffentlicht automatisch.
|
||||
- Änderungen regulär auf dem aktuellen Entwicklungszweig committen. Erst bei einem ausdrücklichen Release-/Merge-/Push-Auftrag nach `main` übernehmen und pushen.
|
||||
- Versionsnummern werden aus den Git-Tags `vMAJOR.MINOR.PATCH` und den Commit-Nachrichten berechnet. Keine manuellen Release-Commits oder Änderungen an Paketversionen für Backend/Frontend erzeugen.
|
||||
- Vor Release-Arbeiten `docs/releases.md` lesen. Vorschau: `npm ci && npm run release:preview`. Prüfungen: `npm test && npm run commits:check`.
|
||||
- Veröffentlichte Versions-Tags und Images niemals überschreiben oder löschen. Einen fehlgeschlagenen Release anhand der dokumentierten Wiederaufnahme fortsetzen.
|
||||
- Mobile-App und Device-Agent behalten eigene Paketversionen; ihr Stand wird im Release-Manifest dokumentiert. App-Store-Veröffentlichungen haben einen eigenen Auslöser.
|
||||
11
CHANGELOG.md
Normal file
11
CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# FEDEO Versionshistorie
|
||||
|
||||
Die Release-Pipeline erzeugt die Versionshistorie automatisch aus Conventional Commits.
|
||||
|
||||
- [Alle veröffentlichten Releases mit Änderungslisten](https://git.federspiel.tech/flfeders/FEDEO/releases)
|
||||
- Jeder Release enthält seine Änderungen zusätzlich als herunterladbare `CHANGELOG.md`.
|
||||
- `release-manifest.json` ordnet Version, Commit, Docker-Digests, Komponenten und Migrationen eindeutig zu.
|
||||
|
||||
Die Versionshistorie vor Einführung der Releases bleibt in der Git-Historie erhalten. Die automatische Reihe beginnt mit `v3.0.0`; Ausgangspunkt ist der in `release.config.json` dokumentierte Commit.
|
||||
|
||||
Die Pipeline schreibt keine automatischen Changelog-Commits zurück nach `main`. Dadurch bleiben Merge-Historie und Release-Auslöser eindeutig.
|
||||
70
README.md
70
README.md
@@ -2,6 +2,14 @@
|
||||
|
||||
Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO.
|
||||
|
||||
## Versionen und Releases
|
||||
|
||||
Ein Push auf `main` veröffentlicht nach erfolgreichen Prüfungen und Builds automatisch die nächste FEDEO-Version. Die Versionsnummer und Release Notes entstehen aus Conventional Commits (`fix:`, `feat:`, Breaking Changes). `dev` bleibt der Entwicklungskanal.
|
||||
|
||||
Für feste Selfhost-Versionen `FEDEO_VERSION` in `.env` auf eine veröffentlichte Version wie `3.0.0` setzen und die Betriebsdateien aus demselben Release verwenden. Ohne gesetzte Version bleibt der bisherige `dev`-Kanal aktiv. Die folgenden `raw/branch/dev`-Beispiele beschreiben diesen Entwicklungskanal.
|
||||
|
||||
Release-Freigabe, Einrichtung der CI-Tokens, versionierte Installation, Updates und Wiederaufnahme sind unter [Versionierung und Releases](docs/releases.md) beschrieben. Die laufende Backend-Version liefert `/backend/version`; im Frontend steht sie unten in der Seitenleiste.
|
||||
|
||||
## Architektur
|
||||
|
||||
Der Stack besteht aus:
|
||||
@@ -239,33 +247,16 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
||||
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
|
||||
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
|
||||
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
|
||||
|
||||
MATRIX_SERVER_NAME=app.example.com
|
||||
MATRIX_POSTGRES_DB=synapse
|
||||
MATRIX_POSTGRES_USER=synapse
|
||||
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
|
||||
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
|
||||
MATRIX_HOMESERVER_URL=http://matrix-synapse:8008
|
||||
MATRIX_RTC_HOST=app.example.com
|
||||
MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt
|
||||
MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu
|
||||
MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret
|
||||
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
|
||||
LIVEKIT_KEY=fedeo-livekit
|
||||
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
|
||||
NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element
|
||||
```
|
||||
|
||||
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
|
||||
|
||||
## Docker Compose mit optionalem S3 und Matrix
|
||||
## Docker Compose mit optionalem S3
|
||||
|
||||
Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
|
||||
|
||||
Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist.
|
||||
|
||||
Der Matrix-Stack ist im Selfhost-Compose direkt enthalten. Er umfasst Synapse, eine eigene PostgreSQL-Datenbank für Synapse, Redis, `.well-known/matrix`, coturn, LiveKit, den LiveKit-JWT-Service und Element Web. Das einfache Selfhost-Setup nutzt nur `DOMAIN`: Synapse läuft unter `https://DOMAIN/_matrix`, Matrix-Well-Known unter `https://DOMAIN/.well-known/matrix`, LiveKit unter `https://DOMAIN/livekit/sfu`, der JWT-Service unter `https://DOMAIN/livekit/jwt` und Element Web unter `https://DOMAIN/element`.
|
||||
|
||||
Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest.
|
||||
|
||||
```yaml
|
||||
@@ -463,7 +454,6 @@ Im Deploy-Verzeichnis:
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Synapse erzeugt `matrix/synapse/homeserver.yaml` beim ersten Start automatisch und aktualisiert die für FEDEO relevanten Werte aus der `.env`. `MATRIX_REGISTRATION_SHARED_SECRET` muss in der `.env` gesetzt und geheim bleiben, weil FEDEO damit Matrix-Nutzer provisioniert.
|
||||
|
||||
Danach Status prufen:
|
||||
|
||||
@@ -497,52 +487,28 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
|
||||
|
||||
## Updates
|
||||
|
||||
Script-installierte Instanzen liegen standardmassig unter `/opt/fedeo`. Ein Update besteht daraus, die Selfhost-Dateien zu erneuern, die vorgebauten Images zu ziehen und den Compose-Stack neu zu starten.
|
||||
|
||||
Vor kritischen Updates sollte ein Backup der persistenten Daten erstellt werden:
|
||||
Vollständige Release-Updates einschließlich Compose-Datei werden mit dem Host-Skript vorbereitet und ausgeführt:
|
||||
|
||||
```bash
|
||||
cd /opt/fedeo
|
||||
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" .env postgres minio
|
||||
sudo python3 /opt/fedeo/scripts/selfhost-update.py check
|
||||
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.1.0
|
||||
sudo python3 /opt/fedeo/scripts/selfhost-update.py status
|
||||
```
|
||||
|
||||
Danach die aktuellen Selfhost-Dateien laden und den Stack aktualisieren:
|
||||
`3.1.0` durch den gewünschten veröffentlichten Release ersetzen. Das Skript benötigt Python ab 3.10 und Docker Compose ab 2.24. Es sichert die lokale Datenbank, MinIO, Konfiguration und Zertifikate vor dem Austausch, erhält `.env` und prüft die gestarteten Versionen. Während Backup und Aktualisierung gibt es eine Wartungsunterbrechung.
|
||||
|
||||
```bash
|
||||
cd /opt/fedeo
|
||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
|
||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
|
||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
|
||||
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||
```
|
||||
Beim ersten Update die erzeugte Compose-Differenz prüfen, eigene Anpassungen in eine Override-Datei übertragen und den Austausch mit `--accept-compose-replacement` bestätigen. Bestehende Installationen müssen das Update-Skript zunächst aus einem passenden Release übernehmen.
|
||||
|
||||
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an, solange `FEDEO_RUN_MIGRATIONS` nicht deaktiviert wurde. Falls Migrationen manuell erzwungen werden sollen:
|
||||
Unter **Administration → Systemstatus → FEDEO-Updates** können Systemadministratoren neue Releases prüfen und nach Bestätigung installieren. Dazu führt `fedeo-updater.service` die Aufträge unabhängig von Browser und Backend-Neustarts aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die aktuelle Selfhost-Compose-Datei bindet dessen lokales Socket-Verzeichnis im Backend ein. Bestehende Installationen benötigen einmalig die aktuellen Hostskripte und ein CLI-Update auf einen Release mit der Oberfläche. Einrichtung und Schritte stehen in der folgenden Anleitung.
|
||||
|
||||
```bash
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Anschliessend den Zustand prüfen:
|
||||
|
||||
```bash
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
|
||||
curl -I https://app.example.com
|
||||
curl https://app.example.com/backend/health
|
||||
```
|
||||
|
||||
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
|
||||
Voraussetzungen, unterstützte Konfigurationen und Wiederherstellung stehen in [Vollständige Selfhost-Updates](docs/selfhost-updates.md). Für ältere Releases steht der manuelle Ablauf in [Versionierung und Releases](docs/releases.md).
|
||||
|
||||
## Backup-Empfehlung
|
||||
|
||||
Regelmassig sichern:
|
||||
|
||||
- `./postgres`
|
||||
- PostgreSQL mit einem konsistenten Datenbankbackup, beispielsweise `pg_dump`
|
||||
- `./minio` falls MinIO lokal genutzt wird
|
||||
- `./matrix/postgres` falls Matrix lokal betrieben wird
|
||||
- `./matrix/synapse` falls Matrix lokal betrieben wird
|
||||
- `./traefik/letsencrypt/acme.json`
|
||||
- deine `.env`
|
||||
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
|
||||
|
||||
@@ -4,3 +4,5 @@ dist
|
||||
.env
|
||||
*.log
|
||||
*.tmp
|
||||
__pycache__
|
||||
tests
|
||||
|
||||
@@ -8,6 +8,7 @@ FEDEO_SCAN_FORMAT=pdf
|
||||
FEDEO_SCAN_RESOLUTION=300
|
||||
FEDEO_SCAN_MODE=Color
|
||||
FEDEO_SCAN_SOURCE=
|
||||
FEDEO_SCAN_TIMEOUT_SECONDS=900
|
||||
FEDEO_SCAN_POSTPROCESS=false
|
||||
FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||
FEDEO_SCAN_POSTPROCESS_PYTHON=
|
||||
|
||||
1
agents/fedeo-device-agent/.gitignore
vendored
1
agents/fedeo-device-agent/.gitignore
vendored
@@ -4,3 +4,4 @@ node_modules
|
||||
.env
|
||||
*.log
|
||||
*.tmp
|
||||
__pycache__/
|
||||
|
||||
@@ -2,8 +2,8 @@ FROM node:20-bookworm-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json tsconfig.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json tsconfig.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
@@ -12,8 +12,8 @@ FROM node:20-bookworm-slim AS runtime
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV FEDEO_WORK_DIR=/work
|
||||
ENV FEDEO_SCAN_POSTPROCESS=true
|
||||
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=receipt
|
||||
ENV FEDEO_SCAN_POSTPROCESS=false
|
||||
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document
|
||||
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
|
||||
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||
|
||||
|
||||
@@ -4,6 +4,41 @@ Der FEDEO Geräte-Agent läuft lokal auf macOS, Linux oder Raspberry Pi OS. Er h
|
||||
|
||||
Der Agent ist nicht an einen Mandanten gebunden. Jeder Auftrag enthält seinen Tenant selbst.
|
||||
|
||||
## Geführte Linux-Installation in FEDEO
|
||||
|
||||
Unter **Administration → Scanner → Agent einrichten** führt ein Assistent durch die Einrichtung:
|
||||
|
||||
1. Rechner benennen und den Agenten anlegen.
|
||||
2. Die vom Scanner-Rechner erreichbare FEDEO-Serveradresse prüfen. Ein Backend-Pfad wie `/backend` muss erhalten bleiben. Optional den Autostart bei der Linux-Anmeldung aktivieren.
|
||||
3. **Linux-Installation herunterladen**. Die Datei enthält die Software und die persönliche Konfiguration; ein Projekt-Checkout oder `npm install` ist nicht nötig.
|
||||
4. Auf dem Scanner-Rechner im Download-Ordner den angezeigten Befehl `bash fedeo-agent-<ID>.sh` ausführen, ohne vorangestelltes `sudo`. Fehlende Pakete werden auf Mint/Ubuntu/Debian über `sudo apt-get` installiert. Node.js muss mindestens Version 20 haben; bei einer älteren Distribution gegebenenfalls zuerst Node.js aktualisieren.
|
||||
5. Der Assistent wartet auf eine aktuelle Meldung des Agenten. Danach den erkannten Scanner und beispielsweise das Profil **ScanSnap iX1600: A4, Duplex, PDF, 300 dpi** wählen und abschließen.
|
||||
6. Optional **Testscan öffnen**. Der eingerichtete Agent ist vorausgewählt. Erst ein Klick auf **Scannen** startet den Einzug; das Ergebnis landet im Hauptordner des aktuellen Mandanten.
|
||||
|
||||
Die Installationsdatei enthält einen Zugangstoken. Nach erfolgreicher Einrichtung löschen oder geschützt aufbewahren. Unter **Einrichtung öffnen** lässt sich ein bestehender Agent erneut installieren. Dafür den gespeicherten Token einfügen oder ausdrücklich einen neuen erzeugen; der bisherige Zugang wird dabei sofort ungültig.
|
||||
|
||||
Die Installation ist je Agent getrennt:
|
||||
|
||||
- Software: `~/.local/share/fedeo-device-agent/<ID>/`
|
||||
- Konfiguration mit Dateirechten `0600`: `~/.config/fedeo-device-agent/<ID>.env`
|
||||
- Arbeitsdateien: `~/.local/state/fedeo-device-agent/<ID>/`
|
||||
- Benutzerdienst: `fedeo-device-agent-<ID>.service`
|
||||
|
||||
Die entsprechenden XDG-Verzeichnisse werden berücksichtigt. Der Autostart beginnt bei der Benutzeranmeldung. Ohne Autostart muss das gestartete Terminal geöffnet bleiben. Protokolle: `journalctl --user -u fedeo-device-agent-<ID>.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten.
|
||||
|
||||
### Scannerverwaltung meldet HTTP 500 nach einem Serverupdate
|
||||
|
||||
Die älteren Scanner-SQL-Dateien `0051` bis `0053` waren nicht im Drizzle-Migrationsjournal eingetragen und wurden dadurch bei regulären Updates übersprungen. Die Vorwärtsmigration `0072_restore_instance_scan_agents` legt fehlende Tabellen und Spalten nachträglich an. Vorhandene Agenten, Tokens, Einstellungen und Scanaufträge bleiben erhalten.
|
||||
|
||||
Zuerst einen Backend-Stand mit dieser Migration ausrollen. Bei aktiviertem `FEDEO_RUN_MIGRATIONS` wird sie beim Start automatisch ausgeführt. Bei bewusst deaktivierten Startmigrationen muss anschließend im aktualisierten Backend `npm run migrate` ausgeführt werden. Die alten Journal-Zeitstempel nachträglich einzufügen genügt bei bereits aktualisierten Datenbanken nicht.
|
||||
|
||||
Der Datenbanktest läuft isoliert gegen ein temporäres PostgreSQL-Image und verwendet keine Anwendungskonfiguration:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx tests/instanceAgentMigration.test.ts
|
||||
```
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
### macOS
|
||||
@@ -13,6 +48,8 @@ brew install node sane-backends
|
||||
scanimage -L
|
||||
```
|
||||
|
||||
Für die Dateierstellung wird Python mit Pillow benötigt, beispielsweise über `npm run setup:opencv` (siehe unten).
|
||||
|
||||
Drucken nutzt später das macOS-Drucksystem/CUPS:
|
||||
|
||||
```bash
|
||||
@@ -23,11 +60,52 @@ lpstat -p
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y nodejs npm sane-utils cups
|
||||
sudo apt install -y nodejs npm sane-utils python3-pil
|
||||
scanimage -L
|
||||
lpstat -p
|
||||
```
|
||||
|
||||
Node.js muss mindestens Version 20 haben (`node --version`). CUPS ist nur für die optionale Druckererkennung erforderlich.
|
||||
|
||||
## ScanSnap iX1600 unter Linux per USB
|
||||
|
||||
Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist `04c5:1632`; die WLAN-Schnittstelle wird von diesem Backend nicht unterstützt. Quellen: [SANE-Geräteliste](https://sane-project.gitlab.io/website/sane-mfgs.html), [Fujitsu-Backend](https://sane-project.gitlab.io/website/man/sane-fujitsu.5.html).
|
||||
|
||||
1. Scanner einschalten und per USB anschließen. `lsusb -d 04c5:1632` und `scanimage -L` müssen ihn anzeigen. Den vollständigen Gerätenamen übernehmen, beispielsweise `fujitsu:ScanSnap iX1600:123456`.
|
||||
2. Den oben beschriebenen Einrichtungsassistenten verwenden. Für eine manuelle Installation kann alternativ die API `POST /api/instance-agents` als Administrator einen Agenten und dessen einmalig ausgegebenen Token erstellen.
|
||||
3. Im Verzeichnis `agents/fedeo-device-agent` die Abhängigkeiten installieren und konfigurieren:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
```env
|
||||
FEDEO_URL=https://deine-fedeo-instanz
|
||||
FEDEO_AGENT_TOKEN=fedeo_agent_DEIN_TOKEN
|
||||
FEDEO_SCANNER_NAME="fujitsu:ScanSnap iX1600:DEINE_SERIENNUMMER"
|
||||
FEDEO_SCAN_SOURCE="ADF Duplex"
|
||||
FEDEO_SCAN_FORMAT=pdf
|
||||
FEDEO_SCAN_RESOLUTION=300
|
||||
FEDEO_SCAN_MODE=Color
|
||||
FEDEO_SCAN_POSTPROCESS=false
|
||||
FEDEO_SCAN_TIMEOUT_SECONDS=900
|
||||
```
|
||||
|
||||
`FEDEO_URL` ist die Basis-URL, unter der `/instance-agent/heartbeat` erreichbar ist, ohne zusätzliches `/api`.
|
||||
4. `npm run build` und `npm start` ausführen. In FEDEO muss der Agent als online erscheinen.
|
||||
5. In der Scannerverwaltung das erkannte Gerät wählen, **ScanSnap iX1600: A4, Duplex, PDF** anklicken und speichern.
|
||||
6. Papier einlegen. Unter **Dateien → Scan** den Agenten wählen und **Scannen** anklicken. Der gesamte Stapel landet als ein PDF im aktuellen Ordner. Für den ersten Test zwei Blätter mit markierten Vorder- und Rückseiten verwenden: Das PDF muss vier Seiten in dieser Reihenfolge enthalten.
|
||||
|
||||
Der Agent liest im SANE-Batch-Modus bis zum leeren Einzug. Vorder- und Rückseiten werden in der vom Scanner gelieferten Reihenfolge gespeichert, einschließlich leerer Rückseiten. Es gibt keine automatische Leerseitenentfernung. „ADF Front“ scannt einen Stapel einseitig, „Flatbed“ eine einzelne Seite. Einzug-Scans benötigen PDF; PNG und TIFF sind für Einzelseiten verfügbar. Die Optionen entsprechen dem [scanimage-Handbuch](https://sane-project.gitlab.io/website/man/scanimage.1.html).
|
||||
|
||||
**Papierformat:** Das iX1600-Profil verwendet A4 mit 210 × 297 mm, einschließlich der Fujitsu-Einstellungen für die Papiergröße. Für andere Größen die Einstellung anpassen; „Scanner-Vorgabe“ übernimmt den am Treiber eingestellten Scanbereich. Die SANE-Quellen und Optionen lassen sich ohne Scan mit `scanimage -d 'DEIN_GERAETENAME' --source 'ADF Duplex' --help` prüfen.
|
||||
|
||||
**USB-Zugriff:** Wenn `lsusb` das Gerät sieht, `scanimage -L` jedoch nicht, zuerst Kabel, Stromversorgung, SANE-Version und Zugriffsrechte prüfen. Debian/Ubuntu/Mint liefern normalerweise passende udev-Regeln mit `libsane-common`. Andere Scanprogramme schließen. Einen dauerhaft laufenden Agenten unter einem Benutzer betreiben, der ebenfalls mit `scanimage -L` auf das Gerät zugreifen kann.
|
||||
|
||||
**Lange Stapel und Fehler:** Das Scan-Zeitlimit beträgt standardmäßig 15 Minuten; die Dateierstellung hat ein eigenes gleich langes Limit. Der Agent hält seinen Online-Status währenddessen aktuell. Der Dialog kann im Hintergrund weiterlaufen; Schließen stoppt den Scan nicht. Verbindungsfehler beim Abfragen des Status lösen keinen neuen Scan aus. Bei Papierstau, unvollständigem Duplex-Scan oder Uploadfehler wird kein automatischer erneuter Papiereinzug gestartet. Die Rohseiten bzw. die fertige Datei bleiben im gemeldeten Arbeitsverzeichnis zur Wiederherstellung erhalten. Nach erfolgreichem Upload werden sie gelöscht. Das Upload-Limit beträgt 100 MiB pro PDF; gegebenenfalls den Stapel teilen oder die Auflösung reduzieren.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
```bash
|
||||
@@ -49,10 +127,18 @@ Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanne
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Nach Änderungen am Agenten oder am Linux-Installationsskript das im Frontend ausgelieferte Paket neu erstellen und mitcommitten:
|
||||
|
||||
```bash
|
||||
npm run build:installer
|
||||
```
|
||||
|
||||
`npm test` prüft mit `check:installer`, dass das Paket dem aktuellen Quellcode entspricht. Das Paket enthält keine Zugangsdaten; diese ergänzt erst der Browser beim Download. Die Installer-Tests verwenden temporäre Verzeichnisse und simulierte Systembefehle.
|
||||
|
||||
## OpenCV-Nachbearbeitung
|
||||
|
||||
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
|
||||
@@ -70,7 +156,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
|
||||
FEDEO_SCAN_POSTPROCESS_STRICT=false
|
||||
```
|
||||
|
||||
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, lädt der Agent den Rohscan hoch, statt den Auftrag komplett fehlschlagen zu lassen.
|
||||
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die Rohseiten im gewünschten Ausgabeformat zusammengeführt. Eine fehlgeschlagene Korrektur verliert keine Seiten. Die Warnung erscheint im Scan-Ergebnis. Beim iX1600-Profil ist die Bildkorrektur zunächst ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt.
|
||||
|
||||
Profile:
|
||||
|
||||
@@ -109,6 +195,17 @@ npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run test:python
|
||||
```
|
||||
|
||||
Die Tests benötigen Python 3 mit Pillow. Sie simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich.
|
||||
|
||||
Hardwareprüfung am 17.09.2026: ScanSnap iX1600 per USB unter Linux Mint 22.3 mit SANE 1.2.1. Zwei A4-Blätter wurden mit 300 dpi in Farbe beidseitig gescannt; das erzeugte PDF enthält vier A4-Seiten in der Reihenfolge Vorderseite/Rückseite. Alle vier Seiten wurden visuell geprüft. Der Test umfasste den lokalen Agenten bis zur PDF-Erstellung; der FEDEO-Upload wird separat mit einem simulierten Dateispeicher getestet.
|
||||
|
||||
## FEDEO-Endpunkte
|
||||
|
||||
Der Agent nutzt:
|
||||
|
||||
@@ -9,8 +9,6 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
FEDEO_WORK_DIR: /work
|
||||
FEDEO_SCAN_POSTPROCESS: "true"
|
||||
FEDEO_SCAN_POSTPROCESS_PROFILE: receipt
|
||||
FEDEO_SCAN_POSTPROCESS_PYTHON: /opt/fedeo-device-agent/.venv-opencv/bin/python
|
||||
FEDEO_SCAN_POSTPROCESS_STRICT: "false"
|
||||
volumes:
|
||||
|
||||
572
agents/fedeo-device-agent/package-lock.json
generated
Normal file
572
agents/fedeo-device-agent/package-lock.json
generated
Normal file
@@ -0,0 +1,572 @@
|
||||
{
|
||||
"name": "@fedeo/device-agent",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@fedeo/device-agent",
|
||||
"version": "0.1.0",
|
||||
"bin": {
|
||||
"fedeo-device-agent": "dist/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz",
|
||||
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,12 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build:installer": "npm run build && node scripts/build-installer.mjs",
|
||||
"check:installer": "npm run build && node scripts/build-installer.mjs --check",
|
||||
"dev": "tsx src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"test": "node --import tsx --test tests/*.test.ts && node --test tests/*.test.mjs && npm run check:installer",
|
||||
"test:python": "python3 -m unittest discover -s tests -p '*_test.py'",
|
||||
"setup:opencv": "sh scripts/setup-opencv.sh"
|
||||
},
|
||||
"dependencies": {},
|
||||
|
||||
40
agents/fedeo-device-agent/scripts/build-installer.mjs
Normal file
40
agents/fedeo-device-agent/scripts/build-installer.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const output = path.resolve(root, '../../frontend/assets/device-agent/bundle.json')
|
||||
async function filesUnder(directory) {
|
||||
const entries = await readdir(path.join(root, directory), { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(entry => entry.isDirectory()
|
||||
? filesUnder(`${directory}/${entry.name}`)
|
||||
: [`${directory}/${entry.name}`]))
|
||||
return paths.flat().sort()
|
||||
}
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
|
||||
const paths = [
|
||||
...(await filesUnder('dist')).filter(file => file.endsWith('.js')),
|
||||
'scripts/scan_document.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh',
|
||||
'requirements-opencv.txt',
|
||||
]
|
||||
const files = Object.fromEntries(await Promise.all(paths.map(async file => [file, (await readFile(path.join(root, file))).toString('base64')])))
|
||||
files['package.json'] = Buffer.from(JSON.stringify({
|
||||
name: packageJson.name, version: packageJson.version, type: 'module', engines: packageJson.engines,
|
||||
scripts: { start: 'node dist/main.js', 'setup:opencv': 'sh scripts/setup-opencv.sh' },
|
||||
}, null, 2) + '\n').toString('base64')
|
||||
const bundle = JSON.stringify({
|
||||
version: packageJson.version,
|
||||
installerTemplate: await readFile(path.join(root, 'scripts/install-linux.sh'), 'utf8'),
|
||||
files,
|
||||
}, null, 2) + '\n'
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
if (await readFile(output, 'utf8') !== bundle) {
|
||||
throw new Error('Das Installationspaket ist veraltet. Bitte npm run build:installer ausführen und bundle.json mitcommitten.')
|
||||
}
|
||||
console.log('Installationspaket entspricht dem aktuellen Agenten.')
|
||||
} else {
|
||||
await mkdir(path.dirname(output), { recursive: true })
|
||||
await writeFile(output, bundle)
|
||||
console.log(`Installationspaket erstellt: ${output}`)
|
||||
}
|
||||
111
agents/fedeo-device-agent/scripts/install-linux.sh
Normal file
111
agents/fedeo-device-agent/scripts/install-linux.sh
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generated by the FEDEO setup wizard. This file contains the agent's access token.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
if [[ "$(uname -s)" != Linux ]]; then
|
||||
echo 'Diese Installation ist für Linux vorgesehen.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(id -u)" == 0 ]]; then
|
||||
echo 'Bitte dieses Skript als normaler Benutzer ohne sudo starten.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${1:-}" != '' && "${1:-}" != --no-start ]]; then
|
||||
echo 'Aufruf: bash install.sh [--no-start]' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FEDEO_SETUP_ID='@@AGENT_ID@@'
|
||||
FEDEO_SETUP_AUTOSTART='@@AUTOSTART@@'
|
||||
FEDEO_SETUP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/fedeo-device-agent/$FEDEO_SETUP_ID"
|
||||
FEDEO_SETUP_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/fedeo-device-agent/$FEDEO_SETUP_ID.env"
|
||||
FEDEO_SETUP_WORK="${XDG_STATE_HOME:-$HOME/.local/state}/fedeo-device-agent/$FEDEO_SETUP_ID"
|
||||
FEDEO_SETUP_UNIT="fedeo-device-agent-$FEDEO_SETUP_ID.service"
|
||||
FEDEO_SETUP_UNITS="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
|
||||
echo 'FEDEO Scan-Agent wird für diesen Benutzer eingerichtet.'
|
||||
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! python3 -c 'import PIL' >/dev/null 2>&1; then
|
||||
if ! command -v apt-get >/dev/null; then
|
||||
echo 'Bitte Node.js >= 20, SANE (scanimage), Python 3 und Pillow installieren und das Skript erneut starten.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo 'Fehlende Systempakete werden über apt installiert (sudo benötigt).'
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nodejs sane-utils python3-pil
|
||||
fi
|
||||
node -e 'if (Number(process.versions.node.split(".")[0]) < 20) { console.error("Node.js >= 20 wird benötigt. Bitte Node.js aktualisieren und die Installation erneut starten."); process.exit(1) }'
|
||||
python3 -c 'import PIL'
|
||||
|
||||
FEDEO_SETUP_NODE="$(command -v node)"
|
||||
FEDEO_SETUP_HAS_SYSTEMD=false
|
||||
if command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; then
|
||||
FEDEO_SETUP_HAS_SYSTEMD=true
|
||||
fi
|
||||
if [[ "$FEDEO_SETUP_AUTOSTART" == true && "$FEDEO_SETUP_HAS_SYSTEMD" != true ]]; then
|
||||
echo 'Der Benutzerdienst von systemd ist nicht erreichbar. Bitte in der Desktop-Sitzung starten oder den Autostart im Assistenten ausschalten.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$FEDEO_SETUP_DIR" "$(dirname "$FEDEO_SETUP_CONFIG")" "$FEDEO_SETUP_WORK" "$FEDEO_SETUP_UNITS"
|
||||
# Stop only the matching installation before replacing its files.
|
||||
if [[ "$FEDEO_SETUP_HAS_SYSTEMD" == true && "${1:-}" != --no-start ]]; then
|
||||
systemctl --user stop "$FEDEO_SETUP_UNIT" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
python3 - "$FEDEO_SETUP_DIR" "$FEDEO_SETUP_CONFIG" "$FEDEO_SETUP_WORK" "$FEDEO_SETUP_NODE" "$FEDEO_SETUP_UNITS/$FEDEO_SETUP_UNIT" <<'FEDEO_PYTHON'
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
agent_dir, config_path, work_dir, node, unit_path = map(Path, sys.argv[1:])
|
||||
files = json.loads(base64.b64decode('@@PAYLOAD_BASE64@@'))
|
||||
for name, encoded in files.items():
|
||||
relative = Path(name)
|
||||
if relative.is_absolute() or '..' in relative.parts:
|
||||
raise ValueError('Ungültiger Dateiname im Installationspaket')
|
||||
target = agent_dir / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(base64.b64decode(encoded, validate=True))
|
||||
|
||||
config = base64.b64decode('@@CONFIG_BASE64@@').decode('utf8')
|
||||
config += 'FEDEO_WORK_DIR=' + str(work_dir) + '\n'
|
||||
config_path.write_text(config, encoding='utf8')
|
||||
config_path.chmod(0o600)
|
||||
|
||||
def unit_quote(value):
|
||||
return '"' + str(value).replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%') + '"'
|
||||
|
||||
unit_path.write_text('\n'.join([
|
||||
'[Unit]', 'Description=FEDEO Scan-Agent', 'After=network-online.target', '',
|
||||
'[Service]', 'Type=simple',
|
||||
'WorkingDirectory=' + unit_quote(agent_dir),
|
||||
'Environment=' + unit_quote('FEDEO_AGENT_ENV=' + str(config_path)),
|
||||
'ExecStart=' + unit_quote(node) + ' ' + unit_quote(agent_dir / 'dist/main.js'),
|
||||
'Restart=on-failure', 'RestartSec=5', 'UMask=0077', '',
|
||||
'[Install]', 'WantedBy=default.target', '',
|
||||
]), encoding='utf8')
|
||||
FEDEO_PYTHON
|
||||
|
||||
echo "Agent installiert: $FEDEO_SETUP_DIR"
|
||||
echo "Konfiguration: $FEDEO_SETUP_CONFIG"
|
||||
if [[ "${1:-}" == --no-start ]]; then
|
||||
echo 'Installation abgeschlossen; Agent wurde nicht gestartet.'
|
||||
elif [[ "$FEDEO_SETUP_AUTOSTART" == true ]]; then
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "$FEDEO_SETUP_UNIT"
|
||||
systemctl --user restart "$FEDEO_SETUP_UNIT"
|
||||
echo 'Agent gestartet. Er startet künftig bei der Anmeldung.'
|
||||
printf 'Protokoll: journalctl --user -u %q -f\n' "$FEDEO_SETUP_UNIT"
|
||||
echo 'Jetzt im FEDEO-Assistenten die Verbindung prüfen. Die heruntergeladene Installationsdatei kann anschließend gelöscht werden.'
|
||||
else
|
||||
if [[ "$FEDEO_SETUP_HAS_SYSTEMD" == true ]]; then
|
||||
systemctl --user disable "$FEDEO_SETUP_UNIT" 2>/dev/null || true
|
||||
systemctl --user daemon-reload
|
||||
fi
|
||||
echo 'Agent startet im Terminal. Dieses Fenster geöffnet lassen; Beenden mit Strg+C.'
|
||||
cd "$FEDEO_SETUP_DIR"
|
||||
FEDEO_AGENT_ENV="$FEDEO_SETUP_CONFIG" "$FEDEO_SETUP_NODE" dist/main.js
|
||||
fi
|
||||
74
agents/fedeo-device-agent/scripts/scan_document.py
Normal file
74
agents/fedeo-device-agent/scripts/scan_document.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble ordered scanner pages; OpenCV is optional, PDF creation is not."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False):
|
||||
if not inputs:
|
||||
raise ValueError("Keine Seiten vorhanden")
|
||||
output = Path(output)
|
||||
file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower())
|
||||
if not file_format:
|
||||
raise ValueError("Unbekanntes Ausgabeformat")
|
||||
if file_format != "PDF" and len(inputs) != 1:
|
||||
raise ValueError("Mehrseitige Scans benötigen PDF als Ausgabeformat")
|
||||
|
||||
warnings = []
|
||||
processor = None
|
||||
if use_postprocess:
|
||||
try:
|
||||
from opencv_postprocess import postprocess
|
||||
processor = postprocess
|
||||
except ImportError:
|
||||
if strict:
|
||||
raise
|
||||
warnings.append("OpenCV fehlt; alle Seiten wurden ohne Bildkorrektur übernommen.")
|
||||
|
||||
partial = output.with_name(output.name + ".partial")
|
||||
try:
|
||||
for index, input_path in enumerate(inputs):
|
||||
input_path = Path(input_path)
|
||||
image_path = input_path
|
||||
corrected = input_path.with_name(input_path.stem + ".corrected.png")
|
||||
if processor:
|
||||
try:
|
||||
processor(input_path, corrected, profile)
|
||||
image_path = corrected
|
||||
except Exception:
|
||||
if strict:
|
||||
raise
|
||||
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.")
|
||||
|
||||
# Only one decoded page is kept in memory, even for large duplex stacks.
|
||||
with Image.open(image_path) as image:
|
||||
with image.convert("RGB") as rgb:
|
||||
if file_format == "PDF":
|
||||
rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0)
|
||||
else:
|
||||
rgb.save(partial, file_format, dpi=(resolution, resolution))
|
||||
corrected.unlink(missing_ok=True)
|
||||
partial.replace(output)
|
||||
finally:
|
||||
partial.unlink(missing_ok=True)
|
||||
|
||||
return {"pageCount": len(inputs), "warnings": warnings}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen")
|
||||
parser.add_argument("--inputs", nargs="+", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--resolution", type=float, required=True)
|
||||
parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document")
|
||||
parser.add_argument("--postprocess", action="store_true")
|
||||
parser.add_argument("--strict", action="store_true")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile, args.strict)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { readFile, stat } from "node:fs/promises"
|
||||
import { basename } from "node:path"
|
||||
import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js"
|
||||
|
||||
@@ -16,10 +16,11 @@ export class FedeoApi {
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
private async request<T>(path: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<T> {
|
||||
const response = await fetch(this.url(path), {
|
||||
...init,
|
||||
headers: this.headers(init.headers),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -51,6 +52,9 @@ export class FedeoApi {
|
||||
}
|
||||
|
||||
async uploadScan(jobId: string, result: ScanResult) {
|
||||
if ((await stat(result.path)).size > 100 * 1024 * 1024) {
|
||||
throw new Error("Die Scan-Datei überschreitet das Upload-Limit von 100 MiB. Bitte den Stapel aufteilen oder die Auflösung reduzieren.")
|
||||
}
|
||||
const form = new FormData()
|
||||
const fileBuffer = await readFile(result.path)
|
||||
const file = new File([fileBuffer], result.filename || basename(result.path), {
|
||||
@@ -62,6 +66,6 @@ export class FedeoApi {
|
||||
return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}, 120_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type CommandResult = {
|
||||
|
||||
export const commandExists = (command: string) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const child = spawn("sh", ["-lc", `command -v ${command}`])
|
||||
const child = spawn("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "--", command])
|
||||
child.on("error", () => resolve(false))
|
||||
child.on("close", (code) => resolve(code === 0))
|
||||
})
|
||||
@@ -16,33 +16,50 @@ export const commandExists = (command: string) =>
|
||||
export const runCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeoutMs?: number } = {}
|
||||
options: { timeoutMs?: number; cwd?: string } = {}
|
||||
) =>
|
||||
new Promise<CommandResult>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: options.cwd,
|
||||
})
|
||||
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
|
||||
let timedOut = false
|
||||
let killTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
|
||||
killTimeout = setTimeout(() => child.kill("SIGKILL"), 2000)
|
||||
}, options.timeoutMs)
|
||||
: null
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
|
||||
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
|
||||
child.on("error", reject)
|
||||
child.on("close", (code) => {
|
||||
child.on("error", (error) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
if (killTimeout) clearTimeout(killTimeout)
|
||||
reject(error)
|
||||
})
|
||||
child.on("close", (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
if (killTimeout) clearTimeout(killTimeout)
|
||||
if (timedOut) {
|
||||
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
|
||||
return
|
||||
}
|
||||
if (signal) {
|
||||
reject(new Error(`${command} wurde durch ${signal} beendet`))
|
||||
return
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||
code: code ?? 0,
|
||||
code: code ?? 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ export const loadConfig = (): AgentConfig => {
|
||||
scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300),
|
||||
scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color",
|
||||
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
|
||||
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
|
||||
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
|
||||
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
|
||||
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { FedeoApi } from "./api.js"
|
||||
import { loadConfig } from "./config.js"
|
||||
import { log } from "./logger.js"
|
||||
import { listPrinters } from "./print/cups.js"
|
||||
import { hasSane, listScanners, runScan } from "./scan/sane.js"
|
||||
import { AgentHeartbeat } from "./types.js"
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -25,11 +28,14 @@ const main = async () => {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const scannerNames = await listScanners()
|
||||
const printerNames = await listPrinters()
|
||||
const scannerNames = await listScanners().catch((error) => {
|
||||
log.warn("Scanner-Erkennung fehlgeschlagen", stringifyError(error))
|
||||
return []
|
||||
})
|
||||
const printerNames = await listPrinters().catch(() => [])
|
||||
const scanAvailable = await hasSane()
|
||||
|
||||
const heartbeat = await api.heartbeat({
|
||||
const heartbeatPayload: AgentHeartbeat = {
|
||||
capabilities: {
|
||||
scan: scanAvailable,
|
||||
print: printerNames.length > 0,
|
||||
@@ -44,7 +50,8 @@ const main = async () => {
|
||||
node: process.version,
|
||||
uptimeSeconds: Math.round(os.uptime()),
|
||||
},
|
||||
})
|
||||
}
|
||||
const heartbeat = await api.heartbeat(heartbeatPayload)
|
||||
|
||||
if (heartbeat.pendingScanJobs > 0) {
|
||||
log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`)
|
||||
@@ -62,14 +69,44 @@ const main = async () => {
|
||||
scannerName: next.job.scannerName || config.scannerName || "default",
|
||||
})
|
||||
|
||||
// Keep online status fresh during long scans without probing an occupied USB scanner.
|
||||
let heartbeatRunning = false
|
||||
const heartbeatTimer = setInterval(async () => {
|
||||
if (heartbeatRunning) return
|
||||
heartbeatRunning = true
|
||||
try {
|
||||
await api.heartbeat(heartbeatPayload)
|
||||
} catch (error) {
|
||||
log.warn("Heartbeat während Scan fehlgeschlagen", stringifyError(error))
|
||||
} finally {
|
||||
heartbeatRunning = false
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
try {
|
||||
await api.updateScanJobStatus(next.job.id, "running")
|
||||
const scanResult = await runScan(config, next.job)
|
||||
await api.uploadScan(next.job.id, scanResult)
|
||||
const jobId = next.job.id
|
||||
const reportProgress = async (message: string) => {
|
||||
await api.updateScanJobStatus(jobId, "running", message).catch((error) => {
|
||||
log.warn("Scan-Status konnte nicht übertragen werden", stringifyError(error))
|
||||
})
|
||||
}
|
||||
const scanResult = await runScan(config, next.job, reportProgress)
|
||||
await reportProgress(`${scanResult.pageCount} Seiten gescannt. ${scanResult.warnings.join(" ")}`.trim())
|
||||
try {
|
||||
await api.uploadScan(jobId, scanResult)
|
||||
} catch (error) {
|
||||
throw new Error(`${stringifyError(error)} Scan-Datei bleibt zur Wiederherstellung unter ${scanResult.path}.`)
|
||||
}
|
||||
|
||||
await rm(path.dirname(scanResult.path), { recursive: true, force: true }).catch((error) => {
|
||||
log.warn("Scan gespeichert; temporäre Dateien konnten nicht entfernt werden", stringifyError(error))
|
||||
})
|
||||
|
||||
log.info("Scan-Auftrag abgeschlossen", {
|
||||
jobId: next.job.id,
|
||||
file: scanResult.filename,
|
||||
pages: scanResult.pageCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = stringifyError(error)
|
||||
@@ -79,6 +116,8 @@ const main = async () => {
|
||||
})
|
||||
|
||||
await api.updateScanJobStatus(next.job.id, "failed", message)
|
||||
} finally {
|
||||
clearInterval(heartbeatTimer)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))
|
||||
|
||||
@@ -1,66 +1,45 @@
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { AgentConfig, ScanResult } from "../types.js"
|
||||
import { commandExists, runCommand } from "../commands.js"
|
||||
import { runCommand } from "../commands.js"
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url)
|
||||
const agentRoot = path.resolve(path.dirname(currentFile), "../..")
|
||||
const postprocessScript = path.join(agentRoot, "scripts/opencv_postprocess.py")
|
||||
const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const documentScript = path.join(agentRoot, "scripts/scan_document.py")
|
||||
|
||||
const extensionMimeTypes: Record<string, string> = {
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => {
|
||||
const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
|
||||
try {
|
||||
const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 })
|
||||
if (result.code === 0) return
|
||||
} catch {
|
||||
// Report missing runtimes before the scanner consumes any paper.
|
||||
}
|
||||
throw new Error("Python/Pillow (bei strikter Nachbearbeitung zusätzlich OpenCV) fehlt. Bitte npm run setup:opencv ausführen oder python3-pil installieren.")
|
||||
}
|
||||
|
||||
const ensureOutputExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
|
||||
const ext = path.extname(filename)
|
||||
if (ext) return filename
|
||||
return `${filename}.${format}`
|
||||
}
|
||||
|
||||
export const hasOpenCvPostprocessRuntime = async (config: AgentConfig) => {
|
||||
if (!await commandExists(config.postprocessPython)) return false
|
||||
|
||||
const result = await runCommand(config.postprocessPython, [
|
||||
"-c",
|
||||
"import cv2, PIL, numpy",
|
||||
], { timeoutMs: 10_000 })
|
||||
|
||||
return result.code === 0
|
||||
}
|
||||
|
||||
export const postprocessScan = async (
|
||||
export const createScanDocument = async (
|
||||
config: AgentConfig,
|
||||
inputPath: string,
|
||||
outputFilename: string,
|
||||
outputFormat: AgentConfig["scanFormat"],
|
||||
pages: string[],
|
||||
outputPath: string,
|
||||
resolution: number,
|
||||
postprocess: boolean,
|
||||
profile: AgentConfig["postprocessProfile"]
|
||||
): Promise<ScanResult> => {
|
||||
const filename = ensureOutputExtension(outputFilename, outputFormat)
|
||||
const outputPath = path.join(config.workDir, filename)
|
||||
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
|
||||
if (postprocess) args.push("--postprocess")
|
||||
if (config.postprocessStrict) args.push("--strict")
|
||||
args.push("--inputs", ...pages)
|
||||
|
||||
const result = await runCommand(config.postprocessPython, [
|
||||
postprocessScript,
|
||||
"--input",
|
||||
inputPath,
|
||||
"--output",
|
||||
outputPath,
|
||||
"--profile",
|
||||
profile,
|
||||
], { timeoutMs: 5 * 60 * 1000 })
|
||||
const result = await runCommand(config.postprocessPython, args, {
|
||||
timeoutMs: config.scanTimeoutSeconds * 1000,
|
||||
})
|
||||
if (result.code !== 0) throw new Error(result.stderr || "Scan-Datei konnte nicht erstellt werden")
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `OpenCV-Nachbearbeitung wurde mit Code ${result.code} beendet`)
|
||||
}
|
||||
|
||||
const extension = path.extname(outputPath).toLowerCase()
|
||||
const details = JSON.parse(result.stdout) as { pageCount: number; warnings: string[] }
|
||||
return {
|
||||
path: outputPath,
|
||||
filename,
|
||||
mimeType: extensionMimeTypes[extension] || "application/octet-stream",
|
||||
filename: path.basename(outputPath),
|
||||
mimeType: outputPath.endsWith(".pdf") ? "application/pdf" : outputPath.endsWith(".png") ? "image/png" : "image/tiff",
|
||||
...details,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +1,118 @@
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { AgentConfig, ScanJob, ScanResult } from "../types.js"
|
||||
import { commandExists, runCommand } from "../commands.js"
|
||||
import { hasOpenCvPostprocessRuntime, postprocessScan } from "./postprocess.js"
|
||||
import { log } from "../logger.js"
|
||||
import { checkScanRuntime, createScanDocument } from "./postprocess.js"
|
||||
|
||||
const mimeTypes = {
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
tiff: "image/tiff",
|
||||
}
|
||||
|
||||
const stringSetting = (settings: Record<string, unknown> | undefined, key: string) => {
|
||||
const value = settings?.[key]
|
||||
const stringSetting = (settings: Record<string, unknown>, key: string) => {
|
||||
const value = settings[key]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
const numberSetting = (settings: Record<string, unknown> | undefined, key: string) => {
|
||||
const value = settings?.[key]
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const booleanSetting = (settings: Record<string, unknown> | undefined, key: string, fallback: boolean) => {
|
||||
const value = settings?.[key]
|
||||
const booleanSetting = (settings: Record<string, unknown>, key: string, fallback: boolean) => {
|
||||
const value = settings[key]
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase())
|
||||
return fallback
|
||||
}
|
||||
|
||||
const profileSetting = (
|
||||
settings: Record<string, unknown> | undefined,
|
||||
fallback: AgentConfig["postprocessProfile"]
|
||||
): AgentConfig["postprocessProfile"] => {
|
||||
const value = settings?.postprocessProfile
|
||||
if (value === "document" || value === "receipt" || value === "raw") return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
const ensureFilenameExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
|
||||
const ext = path.extname(filename)
|
||||
if (!ext) return `${filename}.${format}`
|
||||
|
||||
const expectedExt = `.${format}`
|
||||
if (ext.toLowerCase() === expectedExt) return filename
|
||||
return `${filename.slice(0, -ext.length)}${expectedExt}`
|
||||
}
|
||||
|
||||
const fallbackRawResult = (scanOutputPath: string, jobId: string): ScanResult => ({
|
||||
path: scanOutputPath,
|
||||
filename: `${jobId}.raw.png`,
|
||||
mimeType: "image/png",
|
||||
})
|
||||
|
||||
export const hasSane = () => commandExists("scanimage")
|
||||
|
||||
export const listScanners = async () => {
|
||||
if (!await hasSane()) return []
|
||||
|
||||
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 })
|
||||
if (result.code !== 0) return []
|
||||
|
||||
return result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("device `"))
|
||||
.map((line) => line.match(/device `([^']+)'/)?.[1])
|
||||
return result.stdout.split(/\r?\n/)
|
||||
.map((line) => line.trim().match(/^device `([^']+)'/)?.[1])
|
||||
.filter((device): device is string => Boolean(device))
|
||||
}
|
||||
|
||||
export const runScan = async (config: AgentConfig, job: ScanJob): Promise<ScanResult> => {
|
||||
if (!await hasSane()) {
|
||||
throw new Error("scanimage ist nicht installiert oder nicht im PATH")
|
||||
}
|
||||
|
||||
mkdirSync(config.workDir, { recursive: true })
|
||||
export const runScan = async (
|
||||
config: AgentConfig,
|
||||
job: ScanJob,
|
||||
reportProgress: (message: string) => Promise<void> = async () => {}
|
||||
): Promise<ScanResult> => {
|
||||
if (!await hasSane()) throw new Error("scanimage ist nicht installiert oder nicht im PATH")
|
||||
|
||||
const settings = job.settings || {}
|
||||
const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat
|
||||
const resolution = numberSetting(settings, "resolution") || config.scanResolution
|
||||
const format = stringSetting(settings, "format") || config.scanFormat
|
||||
if (!["pdf", "png", "tiff"].includes(format)) throw new Error("Scan-Format muss PDF, PNG oder TIFF sein")
|
||||
const resolution = Number(settings.resolution ?? config.scanResolution)
|
||||
if (!Number.isFinite(resolution) || resolution < 75 || resolution > 1200) {
|
||||
throw new Error("Die Scan-Auflösung muss zwischen 75 und 1200 dpi liegen")
|
||||
}
|
||||
const mode = stringSetting(settings, "mode") || config.scanMode
|
||||
const source = stringSetting(settings, "source") || config.scanSource
|
||||
const scannerName = job.scannerName || config.scannerName
|
||||
const filename = ensureFilenameExtension(job.requestedFilename || `${job.id}.${format}`, format)
|
||||
const outputPath = path.join(config.workDir, filename)
|
||||
const shouldPostprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
|
||||
const postprocessProfile = profileSetting(settings, config.postprocessProfile)
|
||||
const scanFormat = shouldPostprocess ? "png" : format
|
||||
const scanOutputPath = shouldPostprocess
|
||||
? path.join(config.workDir, `${job.id}.raw.png`)
|
||||
: outputPath
|
||||
const paperSize = stringSetting(settings, "paperSize") || "auto"
|
||||
if (!["auto", "A4", "Letter"].includes(paperSize)) throw new Error("Unbekanntes Papierformat")
|
||||
const batch = Boolean(source && /adf|document feeder/i.test(source))
|
||||
if (batch && format !== "pdf") throw new Error("Für Einzug- und Duplex-Scans bitte PDF als Format wählen")
|
||||
const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
|
||||
const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile
|
||||
if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil")
|
||||
await checkScanRuntime(config, postprocess)
|
||||
|
||||
const args = [
|
||||
"--format",
|
||||
scanFormat,
|
||||
"--resolution",
|
||||
String(resolution),
|
||||
"--mode",
|
||||
mode,
|
||||
"--output-file",
|
||||
scanOutputPath,
|
||||
]
|
||||
// Each physical scan has its own directory. A filename cannot escape it or overwrite another job.
|
||||
await mkdir(config.workDir, { recursive: true, mode: 0o700 })
|
||||
const jobDir = await mkdtemp(path.join(path.resolve(config.workDir), "scan-"))
|
||||
const requested = path.basename((job.requestedFilename || "scan").replace(/\\/g, "/"))
|
||||
const stem = requested.replace(/\.[^.]*$/, "").replace(/[\x00-\x1f]/g, "").trim() || "scan"
|
||||
const outputPath = path.join(jobDir, `${stem}.${format}`)
|
||||
|
||||
if (source) args.push("--source", source)
|
||||
if (scannerName) args.push("--device-name", scannerName)
|
||||
try {
|
||||
// Device/source come first: changing source can change the remaining SANE options.
|
||||
const args: string[] = []
|
||||
if (scannerName) args.push("--device-name", scannerName)
|
||||
if (source) args.push("--source", source)
|
||||
args.push("--mode", mode, "--resolution", String(resolution), "--format=png")
|
||||
if (paperSize !== "auto") {
|
||||
const [width, height] = paperSize === "A4" ? [210, 297] : [215.9, 279.4]
|
||||
if (scannerName?.startsWith("fujitsu:")) {
|
||||
args.push("--page-width", String(width), "--page-height", String(height))
|
||||
}
|
||||
args.push("-l", "0", "-t", "0", "-x", String(width), "-y", String(height))
|
||||
}
|
||||
if (batch) {
|
||||
// Relative pattern avoids interpreting '%' in a user-configured work directory.
|
||||
args.push("--batch=page-%06d.png", "--batch-start=1", "--batch-increment=1")
|
||||
} else {
|
||||
args.push("--output-file", "page-000001.png")
|
||||
}
|
||||
await reportProgress(batch ? "Dokumente werden eingezogen und gescannt..." : "Seite wird gescannt...")
|
||||
const result = await runCommand("scanimage", args, {
|
||||
cwd: jobDir,
|
||||
timeoutMs: config.scanTimeoutSeconds * 1000,
|
||||
})
|
||||
|
||||
const result = await runCommand("scanimage", args, { timeoutMs: 5 * 60 * 1000 })
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `scanimage wurde mit Code ${result.code} beendet`)
|
||||
}
|
||||
|
||||
if (shouldPostprocess) {
|
||||
if (!await hasOpenCvPostprocessRuntime(config)) {
|
||||
const message = "OpenCV-Nachbearbeitung ist aktiviert, aber python3 mit cv2, Pillow und numpy ist nicht verfügbar"
|
||||
if (config.postprocessStrict) throw new Error(message)
|
||||
|
||||
log.warn(`${message}. Rohscan wird ohne Korrektur hochgeladen.`, {
|
||||
jobId: job.id,
|
||||
python: config.postprocessPython,
|
||||
})
|
||||
return fallbackRawResult(scanOutputPath, job.id)
|
||||
// SANE_STATUS_NO_DOCS (7) is the expected end of an unlimited ADF batch.
|
||||
// Never turn paper jams, I/O errors or canceled scans into a successful partial upload.
|
||||
if (result.code !== 0 && !(batch && result.code === 7)) {
|
||||
throw new Error(result.stderr.trim() || `scanimage wurde mit Code ${result.code} beendet`)
|
||||
}
|
||||
const names = (await readdir(jobDir))
|
||||
.filter((name) => /^page-\d+\.png$/.test(name))
|
||||
.sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0]))
|
||||
const pages: string[] = []
|
||||
for (const [index, name] of names.entries()) {
|
||||
const page = path.join(jobDir, name)
|
||||
// Some scanimage versions leave a zero-byte file for the final NO_DOCS attempt.
|
||||
if ((await stat(page)).size === 0) {
|
||||
if (batch && result.code === 7 && index === names.length - 1) continue
|
||||
throw new Error(`Unvollständige Scan-Seite: ${name}`)
|
||||
}
|
||||
if (Number(name.match(/\d+/)![0]) !== index + 1) throw new Error("Im Scan fehlen Seiten")
|
||||
pages.push(page)
|
||||
}
|
||||
if (!pages.length) throw new Error("Keine Seiten gescannt. Bitte Papier in den Einzug legen.")
|
||||
if (source?.toLowerCase().includes("duplex") && pages.length % 2 !== 0) {
|
||||
throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.")
|
||||
}
|
||||
|
||||
try {
|
||||
return await postprocessScan(config, scanOutputPath, filename, format, postprocessProfile)
|
||||
} catch (error) {
|
||||
if (config.postprocessStrict) throw error
|
||||
|
||||
log.warn("OpenCV-Nachbearbeitung fehlgeschlagen. Rohscan wird ohne Korrektur hochgeladen.", {
|
||||
jobId: job.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return fallbackRawResult(scanOutputPath, job.id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: outputPath,
|
||||
filename,
|
||||
mimeType: mimeTypes[format] || "application/octet-stream",
|
||||
await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`)
|
||||
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"])
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AgentConfig = {
|
||||
scanResolution: number
|
||||
scanMode: string
|
||||
scanSource?: string
|
||||
scanTimeoutSeconds: number
|
||||
scanPostprocess: boolean
|
||||
postprocessProfile: "document" | "receipt" | "raw"
|
||||
postprocessPython: string
|
||||
@@ -45,4 +46,6 @@ export type ScanResult = {
|
||||
path: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
pageCount: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
81
agents/fedeo-device-agent/tests/installer.test.mjs
Normal file
81
agents/fedeo-device-agent/tests/installer.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFile, writeFile, mkdir, mkdtemp, stat, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { agentBackendUrl, agentSetupReady, renderAgentInstaller } from '../../../frontend/utils/agentInstaller.mjs'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const bundle = JSON.parse(await readFile(path.resolve(root, '../../frontend/assets/device-agent/bundle.json'), 'utf8'))
|
||||
const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
|
||||
const token = 'fedeo_agent_' + 'a'.repeat(64)
|
||||
const options = { agentId, token, backendUrl: 'https://fedeo.example.com/backend', autostart: true }
|
||||
const execute = promisify(execFile)
|
||||
|
||||
test('backend URLs preserve reverse-proxy paths and reject credentials or non-HTTP protocols', () => {
|
||||
assert.equal(agentBackendUrl('/backend/', 'https://fedeo.example.com'), options.backendUrl)
|
||||
assert.equal(agentBackendUrl('', 'http://localhost:3100'), 'http://localhost:3100')
|
||||
assert.throws(() => agentBackendUrl('javascript:alert(1)'))
|
||||
assert.throws(() => agentBackendUrl('https://user:secret@example.com'))
|
||||
assert.throws(() => agentBackendUrl('https://example.com?token=secret'))
|
||||
})
|
||||
|
||||
test('installer validates tokens and identities before generating executable content', () => {
|
||||
assert.throws(() => renderAgentInstaller(bundle, { ...options, token: 'not-a-token' }))
|
||||
assert.throws(() => renderAgentInstaller(bundle, { ...options, agentId: "x'; touch /tmp/unwanted; '" }))
|
||||
assert.throws(() => renderAgentInstaller(bundle, { ...options, backendUrl: 'https://example.com/\nFEDEO_AGENT_TOKEN=bad' }))
|
||||
const script = renderAgentInstaller(bundle, options)
|
||||
assert.ok(!script.includes('@@'))
|
||||
})
|
||||
|
||||
test('connection readiness requires an active agent and a fresh heartbeat for this setup', () => {
|
||||
const now = Date.now()
|
||||
assert.equal(agentSetupReady({ active: true, lastSeenAt: new Date(now).toISOString() }, now - 1000, now), true)
|
||||
assert.equal(agentSetupReady({ active: false, lastSeenAt: new Date(now).toISOString() }, 0, now), false)
|
||||
assert.equal(agentSetupReady({ active: true, lastSeenAt: new Date(now - 5000).toISOString() }, now - 1000, now), false)
|
||||
assert.equal(agentSetupReady({ active: true, lastSeenAt: 'invalid' }), false)
|
||||
})
|
||||
|
||||
for (const start of [false, true]) {
|
||||
test('downloaded installer writes runtime and credentials; start=' + start, { skip: process.platform !== 'linux' }, async (t) => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), 'fedeo-installer-test-'))
|
||||
t.after(() => rm(temp, { recursive: true, force: true }))
|
||||
const bin = path.join(temp, 'bin')
|
||||
await mkdir(bin)
|
||||
// No package manager, scanner or real systemd command may run during this test.
|
||||
await writeFile(path.join(bin, 'sudo'), '#!/bin/sh\nexit 99\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'scanimage'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'id'), '#!/bin/sh\nprintf "1000\\n"\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'systemctl'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FEDEO_TEST_SYSTEMD_LOG"\n', { mode: 0o755 })
|
||||
const env = {
|
||||
...process.env, PATH: bin + ':' + process.env.PATH,
|
||||
XDG_DATA_HOME: path.join(temp, 'data with spaces %'),
|
||||
XDG_CONFIG_HOME: path.join(temp, 'config'), XDG_STATE_HOME: path.join(temp, 'state'),
|
||||
FEDEO_TEST_SYSTEMD_LOG: path.join(temp, 'systemd.log'),
|
||||
}
|
||||
const scriptPath = path.join(temp, 'install.sh')
|
||||
await writeFile(scriptPath, renderAgentInstaller(bundle, options))
|
||||
await execute('bash', ['-n', scriptPath])
|
||||
const { stdout: output } = await execute('bash', [scriptPath, ...(start ? [] : ['--no-start'])], { env, encoding: 'utf8' })
|
||||
const agentDir = path.join(env.XDG_DATA_HOME, 'fedeo-device-agent', agentId)
|
||||
const configPath = path.join(env.XDG_CONFIG_HOME, 'fedeo-device-agent', agentId + '.env')
|
||||
const config = await readFile(configPath, 'utf8')
|
||||
assert.ok(config.includes('FEDEO_URL=' + options.backendUrl + '\n'))
|
||||
assert.ok(config.includes('FEDEO_AGENT_TOKEN=' + token + '\n'))
|
||||
assert.equal((await stat(configPath)).mode & 0o777, 0o600)
|
||||
assert.ok(!output.includes(token))
|
||||
for (const [file, encoded] of Object.entries(bundle.files)) {
|
||||
assert.equal((await readFile(path.join(agentDir, file))).toString('base64'), encoded)
|
||||
}
|
||||
const unit = await readFile(path.join(env.XDG_CONFIG_HOME, 'systemd/user', 'fedeo-device-agent-' + agentId + '.service'), 'utf8')
|
||||
assert.ok(unit.includes('data with spaces %%'))
|
||||
assert.ok(!unit.includes(token))
|
||||
const calls = await readFile(env.FEDEO_TEST_SYSTEMD_LOG, 'utf8')
|
||||
assert.equal(calls.includes('--user enable fedeo-device-agent-'), start)
|
||||
assert.equal(calls.includes('--user restart fedeo-device-agent-'), start)
|
||||
assert.equal(calls.includes('--user stop fedeo-device-agent-'), start)
|
||||
})
|
||||
}
|
||||
154
agents/fedeo-device-agent/tests/scan.test.ts
Normal file
154
agents/fedeo-device-agent/tests/scan.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test, { TestContext } from "node:test"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
|
||||
import { runScan } from "../src/scan/sane.js"
|
||||
import { commandExists, runCommand } from "../src/commands.js"
|
||||
import { AgentConfig, ScanJob } from "../src/types.js"
|
||||
|
||||
const fakeScanner = `#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
args = sys.argv[1:]
|
||||
Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args))
|
||||
batch = next((arg.split('=', 1)[1] for arg in args if arg.startswith('--batch=')), None)
|
||||
single = args[args.index('--output-file') + 1] if '--output-file' in args else None
|
||||
count = int(os.environ.get('SCAN_TEST_PAGES', '4')) if batch else 1
|
||||
for index in range(1, count + 1):
|
||||
target = batch % index if batch else single
|
||||
Image.new('RGB', (100 + index, 200 + index), (index * 10, 20, 30)).save(target)
|
||||
if batch and os.environ.get('SCAN_TEST_EMPTY_FINAL') == '1':
|
||||
Path(batch % (count + 1)).touch()
|
||||
if os.environ.get('SCAN_TEST_CORRUPT') == '1':
|
||||
Path(batch % 2 if batch else single).write_bytes(b'not a PNG')
|
||||
if os.environ.get('SCAN_TEST_EMPTY_MIDDLE') == '1':
|
||||
Path(batch % 2).write_bytes(b'')
|
||||
sys.stderr.write('simulated scanner status')
|
||||
sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '7' if batch else '0')))
|
||||
`
|
||||
|
||||
const setup = async (t: TestContext) => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-test-"))
|
||||
const oldEnv = { ...process.env }
|
||||
t.after(async () => {
|
||||
for (const key of Object.keys(process.env)) if (!(key in oldEnv)) delete process.env[key]
|
||||
Object.assign(process.env, oldEnv)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
await writeFile(path.join(dir, "scanimage"), fakeScanner)
|
||||
await chmod(path.join(dir, "scanimage"), 0o755)
|
||||
process.env.PATH = `${dir}:${process.env.PATH}`
|
||||
process.env.SCAN_TEST_CALL = path.join(dir, "call.json")
|
||||
const config: AgentConfig = {
|
||||
fedeoUrl: "https://example.invalid", agentToken: "test", pollSeconds: 5,
|
||||
workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test",
|
||||
scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex",
|
||||
scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document",
|
||||
postprocessPython: "python3", postprocessStrict: false,
|
||||
}
|
||||
const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } }
|
||||
return { config, job, dir, args: async () => JSON.parse(await readFile(process.env.SCAN_TEST_CALL!, "utf8")) as string[] }
|
||||
}
|
||||
|
||||
test("duplex batch saves all pages as PDF, accepts NO_DOCS and passes A4 geometry", async (t) => {
|
||||
const { config, job, args } = await setup(t)
|
||||
process.env.SCAN_TEST_EMPTY_FINAL = "1"
|
||||
const progress: string[] = []
|
||||
const result = await runScan(config, job, async (message) => { progress.push(message) })
|
||||
assert.equal(result.pageCount, 4)
|
||||
assert.equal(result.mimeType, "application/pdf")
|
||||
assert.equal((await readFile(result.path)).subarray(0, 5).toString(), "%PDF-")
|
||||
assert.deepEqual(result.warnings, [])
|
||||
const call = await args()
|
||||
assert.ok(call.includes("--batch=page-%06d.png"))
|
||||
assert.ok(!call.includes("--output-file"))
|
||||
assert.ok(!call.includes("--batch-double"))
|
||||
assert.equal(call[call.indexOf("--source") + 1], "ADF Duplex")
|
||||
assert.equal(call[call.indexOf("--page-height") + 1], "297")
|
||||
assert.equal(call[call.indexOf("-y") + 1], "297")
|
||||
assert.ok(progress.at(-1)?.startsWith("4 Seiten"))
|
||||
})
|
||||
|
||||
test("empty ADF reports missing paper instead of creating an empty PDF", async (t) => {
|
||||
const { config, job } = await setup(t)
|
||||
process.env.SCAN_TEST_PAGES = "0"
|
||||
process.env.SCAN_TEST_EMPTY_FINAL = "1"
|
||||
await assert.rejects(runScan(config, job), /Keine Seiten gescannt/)
|
||||
})
|
||||
|
||||
test("paper jam after partial capture fails and keeps raw pages for recovery", async (t) => {
|
||||
const { config, job } = await setup(t)
|
||||
process.env.SCAN_TEST_EXIT = "6"
|
||||
await assert.rejects(runScan(config, job), /simulated scanner status.*Rohscans/)
|
||||
const [jobDir] = await readdir(config.workDir)
|
||||
assert.equal((await readdir(path.join(config.workDir, jobDir))).filter((name) => name.endsWith(".png")).length, 4)
|
||||
})
|
||||
|
||||
test("odd duplex page count cannot silently lose a back side", async (t) => {
|
||||
const { config, job } = await setup(t)
|
||||
process.env.SCAN_TEST_PAGES = "3"
|
||||
await assert.rejects(runScan(config, job), /Rückseite/)
|
||||
})
|
||||
|
||||
test("ADF simplex keeps odd page counts and scanimage success is accepted", async (t) => {
|
||||
const { config, job } = await setup(t)
|
||||
process.env.SCAN_TEST_PAGES = "3"
|
||||
process.env.SCAN_TEST_EXIT = "0"
|
||||
job.settings!.source = "ADF Front"
|
||||
assert.equal((await runScan(config, job)).pageCount, 3)
|
||||
})
|
||||
|
||||
test("corrupt or empty interior pages fail instead of disappearing from a successful PDF", async (t) => {
|
||||
const { config, job } = await setup(t)
|
||||
process.env.SCAN_TEST_CORRUPT = "1"
|
||||
await assert.rejects(runScan(config, job), /cannot identify image file/)
|
||||
delete process.env.SCAN_TEST_CORRUPT
|
||||
process.env.SCAN_TEST_EMPTY_MIDDLE = "1"
|
||||
await assert.rejects(runScan(config, job), /Unvollständige Scan-Seite/)
|
||||
})
|
||||
|
||||
test("non-PDF feeder output and invalid resolution are rejected before feeding paper", async (t) => {
|
||||
const { config, job, args } = await setup(t)
|
||||
job.settings!.format = "png"
|
||||
await assert.rejects(runScan(config, job), /PDF/)
|
||||
delete job.settings!.format
|
||||
job.settings!.resolution = -300
|
||||
await assert.rejects(runScan(config, job), /Auflösung/)
|
||||
await assert.rejects(args(), /ENOENT/)
|
||||
})
|
||||
|
||||
test("missing image runtime is detected before feeding paper", async (t) => {
|
||||
const { config, job, args } = await setup(t)
|
||||
config.postprocessPython = "/not-installed/python"
|
||||
await assert.rejects(runScan(config, job), /Python\/Pillow/)
|
||||
await assert.rejects(args(), /ENOENT/)
|
||||
})
|
||||
|
||||
test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => {
|
||||
const { config, job, args } = await setup(t)
|
||||
job.settings = { source: "Flatbed", format: "png" }
|
||||
job.requestedFilename = "../../outside.pdf"
|
||||
const first = await runScan(config, job)
|
||||
const second = await runScan(config, job)
|
||||
assert.equal(first.filename, "outside.png")
|
||||
assert.equal(first.pageCount, 1)
|
||||
assert.equal(first.mimeType, "image/png")
|
||||
assert.ok(first.path.startsWith(`${config.workDir}${path.sep}`))
|
||||
assert.notEqual(path.dirname(first.path), path.dirname(second.path))
|
||||
assert.ok((await args()).includes("--output-file"))
|
||||
assert.ok(!(await args()).some((arg) => arg.startsWith("--batch")))
|
||||
})
|
||||
|
||||
test("a process terminated by a signal is never reported as a successful scan", async () => {
|
||||
await assert.rejects(runCommand(process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"]), /SIGTERM/)
|
||||
})
|
||||
|
||||
test("a scan timeout terminates the child and reports failure", async () => {
|
||||
await assert.rejects(runCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 50 }), /beendet/)
|
||||
})
|
||||
|
||||
test("command discovery treats executable paths as data", async () => {
|
||||
assert.equal(await commandExists("python3; exit 0"), false)
|
||||
})
|
||||
93
agents/fedeo-device-agent/tests/scan_document_test.py
Normal file
93
agents/fedeo-device-agent/tests/scan_document_test.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, PdfParser
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
from scan_document import create_document
|
||||
|
||||
|
||||
class ScanDocumentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
|
||||
def pages(self, count=4):
|
||||
paths = []
|
||||
for index in range(1, count + 1):
|
||||
target = self.root / f"page-{index:06}.png"
|
||||
# Distinct geometry identifies every front/back page and its order.
|
||||
with Image.new("RGB", (100 + index, 200 + index), "white") as image:
|
||||
image.save(target)
|
||||
paths.append(target)
|
||||
return paths
|
||||
|
||||
def assert_pdf(self, output, count, resolution=300):
|
||||
with PdfParser.PdfParser(filename=str(output)) as pdf:
|
||||
self.assertEqual(len(pdf.pages), count)
|
||||
for index, reference in enumerate(pdf.pages, 1):
|
||||
page = pdf.read_indirect(reference)
|
||||
box = page[b"MediaBox"]
|
||||
self.assertAlmostEqual(box[2], (100 + index) * 72 / resolution, places=4)
|
||||
self.assertAlmostEqual(box[3], (200 + index) * 72 / resolution, places=4)
|
||||
|
||||
def test_twelve_pages_keep_front_back_order_blank_pages_and_scan_resolution(self):
|
||||
output = self.root / "scan.pdf"
|
||||
result = create_document(self.pages(12), output, 150)
|
||||
self.assertEqual(result, {"pageCount": 12, "warnings": []})
|
||||
self.assert_pdf(output, 12, 150)
|
||||
|
||||
def test_missing_opencv_still_creates_complete_pdf(self):
|
||||
output = self.root / "scan.pdf"
|
||||
with patch.dict(sys.modules, {"opencv_postprocess": None}):
|
||||
result = create_document(self.pages(), output, 300, use_postprocess=True)
|
||||
self.assertEqual(len(result["warnings"]), 1)
|
||||
self.assert_pdf(output, 4)
|
||||
|
||||
def test_failed_correction_falls_back_per_page_without_losing_sides(self):
|
||||
def correct(source, target, profile):
|
||||
if source.name.endswith("000002.png"):
|
||||
raise RuntimeError("correction failed")
|
||||
target.write_bytes(source.read_bytes())
|
||||
|
||||
output = self.root / "scan.pdf"
|
||||
with patch.dict(sys.modules, {"opencv_postprocess": SimpleNamespace(postprocess=correct)}):
|
||||
result = create_document(self.pages(), output, 300, use_postprocess=True)
|
||||
self.assertEqual(len(result["warnings"]), 1)
|
||||
self.assertIn("Seite 2", result["warnings"][0])
|
||||
self.assert_pdf(output, 4)
|
||||
|
||||
def test_strict_mode_does_not_silently_skip_missing_correction(self):
|
||||
output = self.root / "scan.pdf"
|
||||
with patch.dict(sys.modules, {"opencv_postprocess": None}):
|
||||
with self.assertRaises(ImportError):
|
||||
create_document(self.pages(), output, 300, use_postprocess=True, strict=True)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_corrupt_page_never_publishes_partial_pdf(self):
|
||||
pages = self.pages()
|
||||
pages[2].write_bytes(b"corrupt")
|
||||
output = self.root / "scan.pdf"
|
||||
with self.assertRaises(OSError):
|
||||
create_document(pages, output, 300)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertFalse((self.root / "scan.pdf.partial").exists())
|
||||
self.assertTrue(all(page.exists() for page in pages))
|
||||
|
||||
def test_single_page_png_and_tiff_remain_supported(self):
|
||||
pages = self.pages(1)
|
||||
for extension in ["png", "tiff"]:
|
||||
output = self.root / f"scan.{extension}"
|
||||
create_document(pages, output, 150)
|
||||
with Image.open(output) as image:
|
||||
self.assertEqual(image.size, (101, 201))
|
||||
self.assertAlmostEqual(image.info["dpi"][0], 150, delta=0.1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,7 +5,6 @@
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"typeRoots": ["../../backend/node_modules/@types"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
|
||||
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-bookworm-slim
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -13,15 +13,18 @@ RUN apt-get update \
|
||||
COPY package*.json ./
|
||||
|
||||
# Dev + Prod Dependencies (für TS-Build nötig).
|
||||
# Sharp benötigt im Linux-Container native optionale Pakete, auch wenn das Lockfile auf macOS erzeugt wurde.
|
||||
RUN npm install --include=optional \
|
||||
&& npm install --include=optional --os=linux --cpu=x64 sharp
|
||||
# Optionale native Linux-Pakete werden aus dem Lockfile installiert.
|
||||
RUN npm ci --include=optional
|
||||
|
||||
# Restlicher Sourcecode
|
||||
COPY . .
|
||||
|
||||
# TypeScript Build
|
||||
RUN npm run build
|
||||
ARG FEDEO_VERSION=0.0.0-dev
|
||||
ARG FEDEO_REVISION=unknown
|
||||
ENV FEDEO_VERSION=$FEDEO_VERSION
|
||||
ENV FEDEO_REVISION=$FEDEO_REVISION
|
||||
RUN npm run build && node --import tsx --test tests/*.test.ts
|
||||
|
||||
# Port freigeben
|
||||
EXPOSE 3100
|
||||
|
||||
@@ -8,9 +8,6 @@ CREATE TABLE "communication_rooms" (
|
||||
"entity_type" text,
|
||||
"entity_id" bigint,
|
||||
"entity_uuid" uuid,
|
||||
"matrix_room_id" text,
|
||||
"matrix_alias" text,
|
||||
"parent_space_room_id" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
|
||||
@@ -8,9 +8,6 @@ CREATE TABLE IF NOT EXISTS "communication_rooms" (
|
||||
"entity_type" text,
|
||||
"entity_id" bigint,
|
||||
"entity_uuid" uuid,
|
||||
"matrix_room_id" text,
|
||||
"matrix_alias" text,
|
||||
"parent_space_room_id" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
|
||||
42
backend/db/migrations/0070_native_communication_chat.sql
Normal file
42
backend/db/migrations/0070_native_communication_chat.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_room_id";
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_alias";
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "parent_space_room_id";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_room_members" (
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade,
|
||||
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "communication_room_members_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "communication_room_members_user_idx"
|
||||
ON "communication_room_members" ("user_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_messages" (
|
||||
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade,
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"author_user_id" uuid NOT NULL REFERENCES "auth_users"("id"),
|
||||
"body" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "communication_messages_room_message_idx"
|
||||
ON "communication_messages" ("room_id", "id");
|
||||
CREATE INDEX IF NOT EXISTS "communication_messages_tenant_idx"
|
||||
ON "communication_messages" ("tenant_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_room_reads" (
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade,
|
||||
"last_read_message_id" bigint,
|
||||
"read_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "communication_room_reads_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id")
|
||||
);
|
||||
|
||||
INSERT INTO "communication_room_members" ("room_id", "user_id")
|
||||
SELECT room.id, tenant_user.user_id
|
||||
FROM "communication_rooms" room
|
||||
JOIN "auth_tenant_users" tenant_user ON tenant_user.tenant_id = room.tenant_id
|
||||
WHERE room.type IN ('general', 'room')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "statementallocations"
|
||||
ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id");
|
||||
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Forward repair: 0051-0053 were shipped without migration journal entries.
|
||||
-- Keep existing agents/jobs when these SQL files were applied manually.
|
||||
CREATE TABLE IF NOT EXISTS "instance_agents" (
|
||||
"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,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"token_prefix" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL,
|
||||
"scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"last_seen_at" timestamp with time zone,
|
||||
"last_debug_info" jsonb,
|
||||
CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "instance_agent_scan_jobs" (
|
||||
"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,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"requested_by" uuid,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"scanner_name" text,
|
||||
"requested_filename" text,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"target" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"agent_message" text,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"claimed_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"file_id" uuid,
|
||||
CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at");
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "preferred_scanner_name" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb;
|
||||
@@ -470,6 +470,27 @@
|
||||
"when": 1788803000000,
|
||||
"tag": "0069_reset_email_entity_suggestions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "7",
|
||||
"when": 1788850800000,
|
||||
"tag": "0070_native_communication_chat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "7",
|
||||
"when": 1790121600000,
|
||||
"tag": "0071_statement_allocation_costcentre",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "7",
|
||||
"when": 1790121600001,
|
||||
"tag": "0072_restore_instance_scan_agents",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
34
backend/db/schema/communication_messages.ts
Normal file
34
backend/db/schema/communication_messages.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
import { tenants } from "./tenants"
|
||||
|
||||
export const communicationMessages = pgTable(
|
||||
"communication_messages",
|
||||
{
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
authorUserId: uuid("author_user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id),
|
||||
body: text("body").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
roomMessageIdx: index("communication_messages_room_message_idx").on(table.roomId, table.id),
|
||||
tenantIdx: index("communication_messages_tenant_idx").on(table.tenantId),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationMessage = typeof communicationMessages.$inferSelect
|
||||
export type NewCommunicationMessage = typeof communicationMessages.$inferInsert
|
||||
26
backend/db/schema/communication_room_members.ts
Normal file
26
backend/db/schema/communication_room_members.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
|
||||
export const communicationRoomMembers = pgTable(
|
||||
"communication_room_members",
|
||||
{
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.roomId, table.userId] }),
|
||||
userIdx: index("communication_room_members_user_idx").on(table.userId),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationRoomMember = typeof communicationRoomMembers.$inferSelect
|
||||
export type NewCommunicationRoomMember = typeof communicationRoomMembers.$inferInsert
|
||||
26
backend/db/schema/communication_room_reads.ts
Normal file
26
backend/db/schema/communication_room_reads.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { bigint, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
|
||||
export const communicationRoomReads = pgTable(
|
||||
"communication_room_reads",
|
||||
{
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
lastReadMessageId: bigint("last_read_message_id", { mode: "number" }),
|
||||
readAt: timestamp("read_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.roomId, table.userId] }),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationRoomRead = typeof communicationRoomReads.$inferSelect
|
||||
export type NewCommunicationRoomRead = typeof communicationRoomReads.$inferInsert
|
||||
@@ -30,10 +30,6 @@ export const communicationRooms = pgTable(
|
||||
entityId: bigint("entity_id", { mode: "number" }),
|
||||
entityUuid: uuid("entity_uuid"),
|
||||
|
||||
matrixRoomId: text("matrix_room_id"),
|
||||
matrixAlias: text("matrix_alias"),
|
||||
parentSpaceRoomId: text("parent_space_room_id"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
|
||||
@@ -16,6 +16,9 @@ export * from "./checkexecutions"
|
||||
export * from "./checks"
|
||||
export * from "./citys"
|
||||
export * from "./communication_rooms"
|
||||
export * from "./communication_room_members"
|
||||
export * from "./communication_messages"
|
||||
export * from "./communication_room_reads"
|
||||
export * from "./contacts"
|
||||
export * from "./contracts"
|
||||
export * from "./contracttypes"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { incominginvoices } from "./incominginvoices"
|
||||
import { createddocuments } from "./createddocuments"
|
||||
import { bankstatements } from "./bankstatements"
|
||||
import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden!
|
||||
import { costcentres } from "./costcentres"
|
||||
|
||||
export const statementallocations = pgTable("statementallocations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -58,6 +59,7 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
|
||||
manualBookingDate: text("manual_booking_date"),
|
||||
datevTaxKey: text("datev_tax_key"),
|
||||
costcentre: uuid("costcentre").references(() => costcentres.id),
|
||||
|
||||
bookingMode: text("booking_mode").notNull().default("expense"),
|
||||
depreciationMonths: integer("depreciation_months"),
|
||||
|
||||
38
backend/package-lock.json
generated
38
backend/package-lock.json
generated
@@ -5794,6 +5794,44 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@infisical/sdk": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@infisical/sdk/-/sdk-4.0.6.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@ import authRoutes from "./routes/auth/auth";
|
||||
import authRoutesAuthenticated from "./routes/auth/auth-authenticated";
|
||||
import authPlugin from "./plugins/auth";
|
||||
import adminRoutes from "./routes/admin";
|
||||
import selfhostUpdateRoutes from "./routes/selfhostUpdates";
|
||||
import corsPlugin from "./plugins/cors";
|
||||
import queryConfigPlugin from "./plugins/queryconfig";
|
||||
import dbPlugin from "./plugins/db";
|
||||
@@ -61,7 +62,6 @@ import {loadSecrets, secrets} from "./utils/secrets";
|
||||
import {initMailer} from "./utils/mailer"
|
||||
import {initS3} from "./utils/s3";
|
||||
import { runBootstrap } from "./modules/bootstrap.service";
|
||||
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
|
||||
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
|
||||
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
|
||||
|
||||
@@ -89,7 +89,6 @@ async function main() {
|
||||
await app.register(dbPlugin);
|
||||
await app.register(servicesPlugin);
|
||||
await runBootstrap(app);
|
||||
startMatrixPushWorker(app);
|
||||
startCentralServicesHeartbeat(app);
|
||||
startDocumentImportWorker(app);
|
||||
|
||||
@@ -100,10 +99,6 @@ async function main() {
|
||||
done()
|
||||
})
|
||||
|
||||
app.get('/health', async (req, res) => {
|
||||
return res.send({ status: 'ok' })
|
||||
})
|
||||
|
||||
//Plugin nur auf bestimmten Routes
|
||||
await app.register(queryConfigPlugin, {
|
||||
routes: ['/api/resource/:resource/paginated']
|
||||
@@ -149,6 +144,7 @@ async function main() {
|
||||
await subApp.register(meRoutes);
|
||||
await subApp.register(tenantRoutes);
|
||||
await subApp.register(adminRoutes);
|
||||
await subApp.register(selfhostUpdateRoutes);
|
||||
await subApp.register(resourceRoutesSpecial);
|
||||
await subApp.register(historyRoutes);
|
||||
await subApp.register(fileRoutes);
|
||||
|
||||
62
backend/src/mcp/documentSchemas.ts
Normal file
62
backend/src/mcp/documentSchemas.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const nullableText = { type: ["string", "null"] }
|
||||
|
||||
export const outgoingAddressSchema = {
|
||||
type: "object",
|
||||
description: "Abweichende Empfängeradresse; Feldnamen entsprechen dem Belegeditor.",
|
||||
properties: { street: nullableText, special: nullableText, zip: nullableText, city: nullableText },
|
||||
additionalProperties: true,
|
||||
}
|
||||
|
||||
export const outgoingRowsSchema = {
|
||||
type: "array",
|
||||
description: "Belegpositionen als Objekte. Beträge numerisch in EUR, unit als Einheiten-ID. Mengen und Preise dürfen für Korrekturpositionen negativ sein.",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "uuid", description: "Optionale Positions-ID; wird sonst erzeugt." },
|
||||
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
||||
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
||||
text: { type: "string" },
|
||||
descriptionText: { type: "string" },
|
||||
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
|
||||
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
||||
quantity: { type: "number", default: 1 },
|
||||
unit: { type: "integer", minimum: 1 },
|
||||
price: { type: "number", description: "Netto-Einzelpreis vor Positionsrabatt." },
|
||||
inputPrice: { type: "number", description: "Eingegebener Einzelpreis; wird ohne price auch als price verwendet." },
|
||||
purchasePrice: { type: "number" },
|
||||
taxPercent: { type: "number", minimum: 0, maximum: 100 },
|
||||
discountPercent: { type: "number", minimum: 0, maximum: 100, default: 0 },
|
||||
costCentre: { type: ["string", "null"], format: "uuid", description: "Positionskostenstelle (abweichende Schreibweise zum Belegfeld costcentre)." },
|
||||
optional: { type: "boolean" },
|
||||
alternative: { type: "boolean" },
|
||||
titleLevel: { type: "integer", minimum: 1, maximum: 3 },
|
||||
linkedEntitys: {
|
||||
type: "array",
|
||||
items: { type: "object", properties: { type: { type: "string" }, subtype: { type: "string" }, id: { type: "integer", minimum: 1 } } },
|
||||
},
|
||||
},
|
||||
// Preserve module-specific position fields used by existing callers.
|
||||
additionalProperties: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const outgoingReportSchema = {
|
||||
type: "object",
|
||||
description: "Optionale, vom Aufrufer berechnete Kalkulationswerte. Diese Felder werden gespeichert, nicht durch MCP berechnet.",
|
||||
properties: {
|
||||
totalProductsPurchasePrice: { type: "number" },
|
||||
totalProductsFromServicesPurchasePrice: { type: "number" },
|
||||
totalMargin: { type: "number" },
|
||||
totalHoursSellingPrice: { type: "number" },
|
||||
totalHoursFromServices: {
|
||||
type: "object",
|
||||
properties: {
|
||||
total: { type: "number" },
|
||||
totalPurchasePrice: { type: "number" },
|
||||
byName: { type: "object", additionalProperties: { type: "number" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
}
|
||||
83
backend/src/mcp/masterdataFields.ts
Normal file
83
backend/src/mcp/masterdataFields.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { getTableColumns } from "drizzle-orm"
|
||||
|
||||
export type InputField = {
|
||||
schema: Record<string, any>
|
||||
parse: (value: unknown, path: string) => any
|
||||
}
|
||||
|
||||
const invalid = (path: string, expected: string): never => { throw new Error(`${path}: ${expected}`) }
|
||||
export const textField = (nonEmpty = false): InputField => ({
|
||||
schema: { type: "string", ...(nonEmpty ? { minLength: 1 } : {}) },
|
||||
parse: (value, path) => typeof value === "string" && (!nonEmpty || value.trim())
|
||||
? value.trim() : invalid(path, "Text" + (nonEmpty ? " darf nicht leer sein" : " erwartet")),
|
||||
})
|
||||
export const numericField = (integer = false, minimum?: number, maximum?: number): InputField => ({
|
||||
schema: { type: integer ? "integer" : "number", ...(minimum !== undefined ? { minimum } : {}), ...(maximum !== undefined ? { maximum } : {}) },
|
||||
parse: (value, path) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || (integer && !Number.isSafeInteger(value))
|
||||
|| (minimum !== undefined && value < minimum) || (maximum !== undefined && value > maximum)) {
|
||||
return invalid(path, `gültige ${integer ? "ganze " : ""}Zahl im angegebenen Bereich erwartet`)
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
export const idField = numericField(true, 1)
|
||||
export const uuidField: InputField = {
|
||||
schema: { type: "string", format: "uuid" },
|
||||
parse: (value, path) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
? value.toLowerCase() : invalid(path, "gültige UUID erwartet"),
|
||||
}
|
||||
export const booleanField: InputField = {
|
||||
schema: { type: "boolean" },
|
||||
parse: (value, path) => typeof value === "boolean" ? value : invalid(path, "Boolean erwartet"),
|
||||
}
|
||||
export const dateField: InputField = {
|
||||
schema: { type: "string", format: "date", description: "Kalenderdatum YYYY-MM-DD." },
|
||||
parse: (value, path) => {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return invalid(path, "Datum YYYY-MM-DD erwartet")
|
||||
const date = new Date(`${value}T00:00:00Z`)
|
||||
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) return invalid(path, "ungültiges Kalenderdatum")
|
||||
return value
|
||||
},
|
||||
}
|
||||
export const nullable = (field: InputField): InputField => ({
|
||||
schema: { ...field.schema, type: [field.schema.type, "null"] },
|
||||
parse: (value, path) => value === null ? null : field.parse(value, path),
|
||||
})
|
||||
export const arrayField = (item: InputField): InputField => ({
|
||||
schema: { type: "array", items: item.schema },
|
||||
parse: (value, path) => Array.isArray(value) ? value.map((entry, index) => item.parse(entry, `${path}[${index}]`)) : invalid(path, "Array erwartet"),
|
||||
})
|
||||
export const objectField = (fields: Record<string, InputField>, additionalProperties = false): InputField => ({
|
||||
schema: { type: "object", properties: Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.schema])), additionalProperties },
|
||||
parse: (value, path) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(path, "Objekt erwartet")
|
||||
const entries = Object.entries(value).map(([key, entry]) => {
|
||||
if (Object.prototype.hasOwnProperty.call(fields, key)) return [key, fields[key].parse(entry, `${path}.${key}`)]
|
||||
if (!additionalProperties || ["__proto__", "constructor", "prototype"].includes(key)) return invalid(`${path}.${key}`, "unbekanntes Feld")
|
||||
return [key, entry]
|
||||
})
|
||||
return Object.fromEntries(entries)
|
||||
},
|
||||
})
|
||||
|
||||
/** Explicit allowlist; scalar types and nullability follow the persisted columns. */
|
||||
export function tableFields(table: any, names: string, overrides: Record<string, InputField> = {}) {
|
||||
const columns = getTableColumns(table) as Record<string, any>
|
||||
return Object.fromEntries(names.split(/\s+/).filter(Boolean).map((name) => {
|
||||
const column = columns[name]
|
||||
if (!column) throw new Error(`Unbekanntes Stammdatenfeld ${name}`)
|
||||
let field = overrides[name]
|
||||
if (!field) {
|
||||
if (column.columnType === "PgUUID") field = uuidField
|
||||
else if (column.columnType === "PgDateString") field = dateField
|
||||
else if (column.dataType === "string") field = textField(name === "name")
|
||||
else if (column.dataType === "boolean") field = booleanField
|
||||
else if (column.dataType === "number") {
|
||||
field = column.columnType === "PgSmallInt" ? numericField(true, -32768, 32767)
|
||||
: numericField(column.columnType !== "PgDoublePrecision")
|
||||
} else throw new Error(`Explizites Eingabeschema für ${name} erforderlich`)
|
||||
}
|
||||
return [name, column.notNull ? field : nullable(field)]
|
||||
}))
|
||||
}
|
||||
31
backend/src/mcp/pagination.ts
Normal file
31
backend/src/mcp/pagination.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/** Offset pagination for a stable dataset; fetch one extra row to detect the next page. */
|
||||
export const paginationProperties = {
|
||||
limit: { type: "integer", minimum: 1, maximum: 100, default: 25 },
|
||||
offset: { type: "integer", minimum: 0, default: 0, description: "Startposition. Für Folgeseiten pagination.nextOffset mit denselben Filtern verwenden." },
|
||||
}
|
||||
|
||||
export function limitFromArgs(args: Record<string, unknown>, fallback = 25) {
|
||||
const value = args.limit ?? fallback
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100) {
|
||||
throw new Error("limit muss eine ganze Zahl zwischen 1 und 100 sein")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function offsetFromArgs(args: Record<string, unknown>) {
|
||||
const value = args.offset ?? 0
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error("offset muss eine nicht negative ganze Zahl sein")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function paginatedResult<T>(rows: T[], args: Record<string, unknown>, fallback = 25) {
|
||||
const limit = limitFromArgs(args, fallback)
|
||||
const offset = offsetFromArgs(args)
|
||||
const hasMore = rows.length > limit
|
||||
return {
|
||||
rows: rows.slice(0, limit),
|
||||
pagination: { limit, offset, hasMore, nextOffset: hasMore ? offset + limit : null },
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,42 @@
|
||||
import { McpToolResult } from "./types"
|
||||
|
||||
const OMIT_ARCHIVED = Symbol("omit-archived")
|
||||
|
||||
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(omitArchivedRecords)
|
||||
.filter((item) => item !== OMIT_ARCHIVED)
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return value
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) return value
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (record.archived === true) return OMIT_ARCHIVED
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(record)
|
||||
.map(([key, item]) => [key, omitArchivedRecords(item)] as const)
|
||||
.filter(([, item]) => item !== OMIT_ARCHIVED),
|
||||
)
|
||||
}
|
||||
|
||||
export function asToolResult(payload: unknown): McpToolResult {
|
||||
const sanitizedPayload = omitArchivedRecords(payload)
|
||||
const resultPayload = sanitizedPayload === OMIT_ARCHIVED ? {} : sanitizedPayload
|
||||
const structuredContent =
|
||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? payload as Record<string, unknown>
|
||||
: { result: payload }
|
||||
resultPayload && typeof resultPayload === "object" && !Array.isArray(resultPayload)
|
||||
? resultPayload as Record<string, unknown>
|
||||
: { result: resultPayload }
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
text: JSON.stringify(resultPayload, null, 2),
|
||||
},
|
||||
],
|
||||
structuredContent,
|
||||
@@ -33,4 +59,3 @@ export function asToolError(error: unknown): McpToolResult {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeBankAllocationDetails } from "../modules/bank-allocation-details"
|
||||
|
||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
@@ -110,6 +112,10 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
|
||||
if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide
|
||||
}
|
||||
|
||||
const details = normalizeBankAllocationDetails({ ...payload, costcentre: args.costcentre })
|
||||
if (args.costcentre !== undefined) payload.costcentre = details.costcentre
|
||||
if (args.datevTaxKey !== undefined) payload.datevTaxKey = details.datevTaxKey
|
||||
|
||||
if (bookingMode === "expense") return payload
|
||||
|
||||
const depreciationMonths = requiredNumber(args, "depreciationMonths")
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
|
||||
import {
|
||||
accounts,
|
||||
bankstatements,
|
||||
@@ -10,12 +13,15 @@ import {
|
||||
folders,
|
||||
incominginvoices,
|
||||
ownaccounts,
|
||||
serialExecutions,
|
||||
statementallocations,
|
||||
vendors,
|
||||
} from "../../../db/schema"
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||
import { saveFile } from "../../utils/files"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||
import {
|
||||
prepareStatementAllocationInput,
|
||||
statementAllocationUuidArg,
|
||||
@@ -24,11 +30,6 @@ import { McpTool } from "../types"
|
||||
|
||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||
|
||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||
const raw = Number(args.limit ?? fallback)
|
||||
if (!Number.isFinite(raw)) return fallback
|
||||
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
||||
}
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
@@ -43,6 +44,22 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
|
||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
||||
const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
const serialConfigSchema = {
|
||||
type: "object",
|
||||
description: "Ausführungsplan einer Serienrechnungsvorlage.",
|
||||
properties: {
|
||||
firstExecution: { type: "string", description: "Datum der ersten Ausführung als ISO-8601-Wert." },
|
||||
executionUntil: { type: ["string", "null"], description: "Optionales Datum der letzten Ausführung als ISO-8601-Wert." },
|
||||
intervall: {
|
||||
type: "string",
|
||||
enum: ["wöchentlich", "2 - wöchentlich", "monatlich", "vierteljährlich", "halbjährlich", "jährlich"],
|
||||
},
|
||||
active: { type: "boolean" },
|
||||
dateDirection: { type: "string", enum: ["Rückwirkend", "Im Voraus"] },
|
||||
},
|
||||
}
|
||||
|
||||
const allowedOutgoingDocumentTypes = new Set([
|
||||
"quotes",
|
||||
@@ -390,6 +407,7 @@ const assertTenantEntityExists = async (
|
||||
|
||||
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
|
||||
await Promise.all([
|
||||
validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre),
|
||||
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
|
||||
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
|
||||
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"),
|
||||
@@ -437,8 +455,7 @@ export const accountingTools: McpTool[] = [
|
||||
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
||||
customer: { type: "number" },
|
||||
project: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -455,16 +472,17 @@ export const accountingTools: McpTool[] = [
|
||||
if (state) conditions.push(eq(createddocuments.state, state))
|
||||
if (customer) conditions.push(eq(createddocuments.customer, customer))
|
||||
if (project) conditions.push(eq(createddocuments.project, project))
|
||||
if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false))
|
||||
conditions.push(eq(createddocuments.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(createddocuments.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(desc(createddocuments.createdAt), createddocuments.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -486,7 +504,7 @@ export const accountingTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||
@@ -502,7 +520,7 @@ export const accountingTools: McpTool[] = [
|
||||
type: "object",
|
||||
required: ["type"],
|
||||
properties: {
|
||||
type: { type: "string" },
|
||||
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
||||
customer: { type: "number" },
|
||||
contact: { type: "number" },
|
||||
contract: { type: "number" },
|
||||
@@ -524,12 +542,13 @@ export const accountingTools: McpTool[] = [
|
||||
description: { type: "string" },
|
||||
startText: { type: "string" },
|
||||
endText: { type: "string" },
|
||||
address: { type: "object" },
|
||||
rows: { type: "array" },
|
||||
address: outgoingAddressSchema,
|
||||
rows: outgoingRowsSchema,
|
||||
letterhead: { type: "number" },
|
||||
availableInPortal: { type: "boolean" },
|
||||
customSurchargePercentage: { type: "number" },
|
||||
report: { type: "object" },
|
||||
report: outgoingReportSchema,
|
||||
serialConfig: serialConfigSchema,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -556,7 +575,7 @@ export const accountingTools: McpTool[] = [
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
type: { type: "string" },
|
||||
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
||||
state: { type: "string" },
|
||||
customer: { type: "number" },
|
||||
contact: { type: "number" },
|
||||
@@ -579,12 +598,13 @@ export const accountingTools: McpTool[] = [
|
||||
description: { type: "string" },
|
||||
startText: { type: "string" },
|
||||
endText: { type: "string" },
|
||||
address: { type: "object" },
|
||||
rows: { type: "array" },
|
||||
address: outgoingAddressSchema,
|
||||
rows: outgoingRowsSchema,
|
||||
letterhead: { type: "number" },
|
||||
availableInPortal: { type: "boolean" },
|
||||
customSurchargePercentage: { type: "number" },
|
||||
report: { type: "object" },
|
||||
report: outgoingReportSchema,
|
||||
serialConfig: serialConfigSchema,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -614,6 +634,72 @@ export const accountingTools: McpTool[] = [
|
||||
return { document: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.cost_centres.update",
|
||||
title: "Kostenstellen eines Ausgangsbelegs ändern",
|
||||
description: "Ändert ausschließlich die Beleg- und Positionskostenstellen eines bereits fertiggestellten Ausgangsbelegs.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
anyOf: [
|
||||
{ required: ["costcentre"] },
|
||||
{ required: ["rowCostCentres"] },
|
||||
],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
costcentre: { type: ["string", "null"], description: "UUID der Beleg-Kostenstelle oder null." },
|
||||
rowCostCentres: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["rowId", "costcentre"],
|
||||
properties: {
|
||||
rowId: { type: "string", description: "ID der Belegposition." },
|
||||
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = numberArg(args, "id")
|
||||
if (!id) throw new Error("id ist erforderlich")
|
||||
|
||||
const input: {
|
||||
costcentre?: string | null
|
||||
rowCostCentres?: Array<{ rowId: string, costcentre: string | null }>
|
||||
} = {}
|
||||
if (Object.prototype.hasOwnProperty.call(args, "costcentre")) {
|
||||
const costcentre = args.costcentre
|
||||
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||
throw new Error("costcentre muss eine gültige UUID oder null sein")
|
||||
}
|
||||
input.costcentre = costcentre as string | null
|
||||
}
|
||||
if (args.rowCostCentres !== undefined) {
|
||||
if (!Array.isArray(args.rowCostCentres)) throw new Error("rowCostCentres muss ein Array sein")
|
||||
input.rowCostCentres = args.rowCostCentres.map((assignment: any) => {
|
||||
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
|
||||
const costcentre = assignment?.costcentre
|
||||
if (!rowId) throw new Error("Jede Positionszuordnung benötigt eine rowId")
|
||||
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||
throw new Error(`Ungültige Kostenstelle für Position ${rowId}`)
|
||||
}
|
||||
return { rowId, costcentre }
|
||||
})
|
||||
}
|
||||
|
||||
const document = await updateOutgoingDocumentCostCentres(
|
||||
context.server,
|
||||
context.tenantId,
|
||||
context.userId,
|
||||
id,
|
||||
input,
|
||||
)
|
||||
return { document }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.finalize",
|
||||
title: "Ausgangsbeleg finalisieren",
|
||||
@@ -634,8 +720,8 @@ export const accountingTools: McpTool[] = [
|
||||
enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"],
|
||||
description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.",
|
||||
},
|
||||
rows: { type: "array" },
|
||||
report: { type: "object" },
|
||||
rows: outgoingRowsSchema,
|
||||
report: outgoingReportSchema,
|
||||
availableInPortal: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
@@ -669,6 +755,91 @@ export const accountingTools: McpTool[] = [
|
||||
return { document: updated }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.serial_invoices.execute",
|
||||
title: "Serienrechnungslauf starten",
|
||||
description: "Erzeugt aus ausgewählten aktiven Serienrechnungsvorlagen einen neuen Rechnungslauf.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["executionDate", "templateIds"],
|
||||
properties: {
|
||||
executionDate: { type: "string", description: "Ausführungsdatum als ISO-8601-Wert." },
|
||||
templateIds: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
items: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const executionDate = stringArg(args, "executionDate")
|
||||
const parsedExecutionDate = executionDate ? new Date(executionDate) : null
|
||||
const templateIds = Array.isArray(args.templateIds)
|
||||
? [...new Set(args.templateIds.map(Number).filter((id) => Number.isFinite(id) && id > 0))]
|
||||
: []
|
||||
|
||||
if (!parsedExecutionDate || Number.isNaN(parsedExecutionDate.getTime())) {
|
||||
throw new Error("executionDate muss ein gültiger ISO-8601-Wert sein")
|
||||
}
|
||||
if (!templateIds.length) throw new Error("templateIds muss mindestens eine gültige ID enthalten")
|
||||
|
||||
return executeManualGeneration(
|
||||
context.server,
|
||||
parsedExecutionDate,
|
||||
templateIds,
|
||||
context.tenantId,
|
||||
context.userId,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.serial_invoice_executions.list",
|
||||
title: "Serienrechnungsläufe auflisten",
|
||||
description: "Listet die zuletzt gestarteten Serienrechnungsläufe des aktiven Mandanten.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["draft", "completed", "error"] },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const conditions = [eq(serialExecutions.tenant, context.tenantId)]
|
||||
const status = stringArg(args, "status")
|
||||
if (status) conditions.push(eq(serialExecutions.status, status))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(serialExecutions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(serialExecutions.createdAt), serialExecutions.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.serial_invoice_executions.finish",
|
||||
title: "Serienrechnungslauf abschließen",
|
||||
description: "Finalisiert die erzeugten Rechnungen eines Serienrechnungslaufs und schließt den Lauf ab.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string", description: "UUID des Serienrechnungslaufs." },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const id = stringArg(args, "id")
|
||||
if (!id || !UUID_PATTERN.test(id)) throw new Error("id muss eine gültige UUID sein")
|
||||
return finishManualGeneration(context.server, id, context.tenantId)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounting.outgoing_documents.archive",
|
||||
title: "Ausgangsbeleg archivieren",
|
||||
@@ -708,7 +879,7 @@ export const accountingTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -738,10 +909,11 @@ export const accountingTools: McpTool[] = [
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(whereCond)
|
||||
.orderBy(accounts.number)
|
||||
.limit(limit)
|
||||
.orderBy(accounts.number, accounts.id)
|
||||
.limit(limit + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { accountChart, rows }
|
||||
return { accountChart, ...paginatedResult(rows, args) }
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -754,8 +926,7 @@ export const accountingTools: McpTool[] = [
|
||||
properties: {
|
||||
state: { type: "string", description: "Optionaler Statusfilter." },
|
||||
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -764,16 +935,17 @@ export const accountingTools: McpTool[] = [
|
||||
|
||||
if (state) conditions.push(eq(incominginvoices.state, state))
|
||||
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
|
||||
if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false))
|
||||
conditions.push(eq(incominginvoices.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(incominginvoices.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(desc(incominginvoices.createdAt), incominginvoices.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -795,7 +967,7 @@ export const accountingTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(incominginvoices)
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId), eq(incominginvoices.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
|
||||
@@ -812,6 +984,7 @@ export const accountingTools: McpTool[] = [
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -826,9 +999,11 @@ export const accountingTools: McpTool[] = [
|
||||
eq(files.tenant, context.tenantId),
|
||||
eq(files.archived, false)
|
||||
))
|
||||
.orderBy(desc(files.createdAt))
|
||||
.orderBy(desc(files.createdAt), files.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1142,8 +1317,7 @@ export const accountingTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -1151,16 +1325,17 @@ export const accountingTools: McpTool[] = [
|
||||
const account = numberArg(args, "account")
|
||||
|
||||
if (account) conditions.push(eq(bankstatements.account, account))
|
||||
if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false))
|
||||
conditions.push(eq(bankstatements.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(bankstatements.date), desc(bankstatements.id))
|
||||
.limit(limitFromArgs(args))
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1182,7 +1357,7 @@ export const accountingTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(bankstatements)
|
||||
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId)))
|
||||
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId), eq(bankstatements.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
|
||||
@@ -1199,8 +1374,7 @@ export const accountingTools: McpTool[] = [
|
||||
properties: {
|
||||
bankstatement: { type: "number" },
|
||||
incominginvoice: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -1210,16 +1384,17 @@ export const accountingTools: McpTool[] = [
|
||||
|
||||
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
|
||||
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
|
||||
if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false))
|
||||
conditions.push(eq(statementallocations.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(statementallocations)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(statementallocations.created_at))
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(desc(statementallocations.created_at), statementallocations.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1246,7 +1421,8 @@ export const accountingTools: McpTool[] = [
|
||||
manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." },
|
||||
manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
|
||||
description: { type: "string" },
|
||||
datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." },
|
||||
datevTaxKey: { type: "string", description: "DATEV-Steuerschlüssel auf den Bruttobetrag: 9/8 für 19/7 % Vorsteuer, 3/2 für 19/7 % Umsatzsteuer, 40 ohne Steuer (Automatik aufheben)." },
|
||||
costcentre: { type: ["string", "null"], description: "Kostenstellen-UUID für direkte Bankbuchungen auf Sachkonten oder zusätzliche Konten." },
|
||||
bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" },
|
||||
depreciationMonths: { type: "number", minimum: 1 },
|
||||
depreciationStartDate: { type: "string", format: "date" },
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { masterdataMutationTools } from "./masterdataWrites"
|
||||
import { organisationTools } from "./organisation"
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||
import {
|
||||
branches,
|
||||
@@ -14,11 +17,6 @@ import {
|
||||
} from "../../../db/schema"
|
||||
import { McpTool } from "../types"
|
||||
|
||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||
const raw = Number(args.limit ?? fallback)
|
||||
if (!Number.isFinite(raw)) return fallback
|
||||
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
||||
}
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
@@ -38,6 +36,12 @@ const uuidArg = (args: Record<string, unknown>, key: string) => {
|
||||
}
|
||||
|
||||
export const masterdataTools: McpTool[] = [
|
||||
...masterdataMutationTools,
|
||||
{
|
||||
...organisationTools.find((tool) => tool.name === "organisation.customers.search")!,
|
||||
name: "masterdata.customers.search",
|
||||
requiredPermissions: ["masterdata.customers.read"],
|
||||
},
|
||||
{
|
||||
name: "masterdata.customers.get",
|
||||
title: "Kunde laden",
|
||||
@@ -55,7 +59,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
|
||||
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId), eq(customers.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Kunde nicht gefunden")
|
||||
@@ -71,8 +75,7 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -86,16 +89,17 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(vendors.notes, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
|
||||
conditions.push(eq(vendors.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(vendors)
|
||||
.where(and(...conditions))
|
||||
.orderBy(vendors.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(vendors.name, vendors.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -115,7 +119,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(vendors)
|
||||
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
|
||||
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId), eq(vendors.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
|
||||
@@ -133,8 +137,7 @@ export const masterdataTools: McpTool[] = [
|
||||
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
||||
customer: { type: "number" },
|
||||
vendor: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -157,16 +160,17 @@ export const masterdataTools: McpTool[] = [
|
||||
}
|
||||
if (customer) conditions.push(eq(contacts.customer, customer))
|
||||
if (vendor) conditions.push(eq(contacts.vendor, vendor))
|
||||
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
|
||||
conditions.push(eq(contacts.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(...conditions))
|
||||
.orderBy(contacts.fullName)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(contacts.fullName, contacts.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -178,8 +182,7 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -197,16 +200,17 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(products.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
|
||||
conditions.push(eq(products.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(...conditions))
|
||||
.orderBy(products.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(products.name, products.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -226,7 +230,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
|
||||
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId), eq(products.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Artikel nicht gefunden")
|
||||
@@ -242,8 +246,7 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -256,16 +259,17 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(services.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
|
||||
conditions.push(eq(services.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(...conditions))
|
||||
.orderBy(services.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(services.name, services.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -285,7 +289,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
|
||||
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId), eq(services.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Leistung nicht gefunden")
|
||||
@@ -303,8 +307,7 @@ export const masterdataTools: McpTool[] = [
|
||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||
branch: { type: "number" },
|
||||
project: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -322,16 +325,17 @@ export const masterdataTools: McpTool[] = [
|
||||
}
|
||||
if (branch) conditions.push(eq(costcentres.branch, branch))
|
||||
if (project) conditions.push(eq(costcentres.project, project))
|
||||
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
|
||||
conditions.push(eq(costcentres.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(costcentres)
|
||||
.where(and(...conditions))
|
||||
.orderBy(costcentres.number)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(costcentres.number, costcentres.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -351,7 +355,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(costcentres)
|
||||
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
|
||||
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId), eq(costcentres.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
|
||||
@@ -367,8 +371,7 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -382,16 +385,17 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(branches.description, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
|
||||
conditions.push(eq(branches.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(branches)
|
||||
.where(and(...conditions))
|
||||
.orderBy(branches.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(branches.name, branches.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -404,8 +408,7 @@ export const masterdataTools: McpTool[] = [
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
||||
branch: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -420,16 +423,17 @@ export const masterdataTools: McpTool[] = [
|
||||
))
|
||||
}
|
||||
if (branch) conditions.push(eq(teams.branch, branch))
|
||||
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
|
||||
conditions.push(eq(teams.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(teams)
|
||||
.where(and(...conditions))
|
||||
.orderBy(teams.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(teams.name, teams.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -441,8 +445,7 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -457,16 +460,17 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(vehicles.color, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
|
||||
conditions.push(eq(vehicles.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(vehicles)
|
||||
.where(and(...conditions))
|
||||
.orderBy(vehicles.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(vehicles.name, vehicles.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -479,8 +483,7 @@ export const masterdataTools: McpTool[] = [
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
||||
vendor: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -499,16 +502,17 @@ export const masterdataTools: McpTool[] = [
|
||||
))
|
||||
}
|
||||
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
|
||||
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
|
||||
conditions.push(eq(inventoryitems.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(inventoryitems)
|
||||
.where(and(...conditions))
|
||||
.orderBy(inventoryitems.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(inventoryitems.name, inventoryitems.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -528,7 +532,7 @@ export const masterdataTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(inventoryitems)
|
||||
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
|
||||
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId), eq(inventoryitems.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Inventar nicht gefunden")
|
||||
@@ -544,7 +548,7 @@ export const masterdataTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Singular, Plural oder Kürzel." },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -561,11 +565,11 @@ export const masterdataTools: McpTool[] = [
|
||||
ilike(units.short, `%${query}%`)
|
||||
)
|
||||
: undefined)
|
||||
.orderBy(units.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(units.name, units.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
277
backend/src/mcp/tools/masterdataWrites.ts
Normal file
277
backend/src/mcp/tools/masterdataWrites.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"
|
||||
import * as tables from "../../../db/schema"
|
||||
import { assertToolPermission } from "../authz"
|
||||
import { McpContext, McpTool } from "../types"
|
||||
import { arrayField, booleanField, idField, InputField, nullable, numericField, objectField, tableFields, textField, uuidField } from "../masterdataFields"
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
||||
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
|
||||
|
||||
const infoDataField = objectField({
|
||||
...Object.fromEntries("street special zip city country tel mobileTel email invoiceEmail web ustid".split(" ").map((key) => [key, nullable(textField())])),
|
||||
bankAccountIds: arrayField(idField),
|
||||
hasSEPA: booleanField,
|
||||
sepaSignedAt: nullable(textField()),
|
||||
}, true)
|
||||
infoDataField.schema.description = "Kontaktdaten und Zusatzfelder. Bei update werden angegebene Unterfelder mit infoData zusammengeführt; Arrays werden ersetzt."
|
||||
const profilesField = arrayField(uuidField)
|
||||
const compositionField = arrayField(objectField({
|
||||
id: uuidField, name: textField(), quantity: numericField(), price: numericField(), purchasePrice: numericField(),
|
||||
product: nullable(idField), service: nullable(idField), hourrate: nullable(uuidField),
|
||||
}, true))
|
||||
const priceField = objectField({ total: numericField(), material: numericField(), worker: numericField() }, true)
|
||||
|
||||
type Reference = { field: string; table: any; idKey?: string; tenantKey?: string }
|
||||
type Config = {
|
||||
resource: string
|
||||
namespace: string
|
||||
label: string
|
||||
resultKey: string
|
||||
table: any
|
||||
fields: Record<string, InputField>
|
||||
required: string[]
|
||||
numberField?: string
|
||||
references?: Reference[]
|
||||
addGet?: boolean
|
||||
}
|
||||
const makeConfig = (config: Omit<Config, "fields"> & { fieldNames: string; overrides?: Record<string, InputField> }): Config => ({
|
||||
...config,
|
||||
fields: tableFields(config.table, config.fieldNames, config.overrides),
|
||||
})
|
||||
const commonProfiles: Reference = { field: "profiles", table: tables.authProfiles, tenantKey: "tenant_id" }
|
||||
const bankAccounts: Reference = { field: "infoData.bankAccountIds", table: tables.entitybankaccounts }
|
||||
|
||||
export const masterdataWriteConfigs: Config[] = [
|
||||
makeConfig({
|
||||
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
|
||||
required: ["name"], numberField: "customerNumber",
|
||||
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation",
|
||||
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
|
||||
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
|
||||
required: ["name"], numberField: "vendorNumber",
|
||||
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod",
|
||||
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
|
||||
references: [commonProfiles, bankAccounts],
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "contacts", namespace: "contacts", label: "Kontakt", resultKey: "contact", table: tables.contacts, required: [], addGet: true,
|
||||
fieldNames: "firstName lastName fullName email customer vendor phoneMobile phoneHome role salutation active birthday notes profiles title",
|
||||
overrides: { profiles: profilesField, customer: idField, vendor: idField },
|
||||
references: [commonProfiles, { field: "customer", table: tables.customers }, { field: "vendor", table: tables.vendors }],
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "products", namespace: "products", label: "Artikel", resultKey: "product", table: tables.products, required: ["name", "unit"],
|
||||
fieldNames: "name manufacturer unit tags ean barcode purchase_price selling_price description manufacturer_number vendor_allocation article_number supplier_link barcodes productcategories tax_percentage markup_percentage",
|
||||
overrides: {
|
||||
unit: idField, tags: arrayField(textField()), barcodes: arrayField(textField()), productcategories: arrayField(idField), tax_percentage: numericField(true, 0, 100),
|
||||
vendor_allocation: arrayField(objectField({ vendor: idField, articleNumber: textField(), purchasePrice: numericField() }, true)),
|
||||
},
|
||||
references: [{ field: "unit", table: tables.units }, { field: "productcategories", table: tables.productcategories }, { field: "vendor_allocation.vendor", table: tables.vendors }],
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "services", namespace: "services", label: "Leistung", resultKey: "service", table: tables.services, required: ["name"],
|
||||
fieldNames: "name sellingPrice description unit costcentre serviceNumber tags servicecategories purchasePriceComposed sellingPriceComposed taxPercentage materialComposition personalComposition priceUpdateLocked",
|
||||
overrides: {
|
||||
unit: idField, tags: arrayField(textField()), servicecategories: arrayField(idField), taxPercentage: numericField(true, 0, 100),
|
||||
purchasePriceComposed: priceField, sellingPriceComposed: priceField, materialComposition: compositionField, personalComposition: compositionField,
|
||||
},
|
||||
references: [
|
||||
{ field: "unit", table: tables.units }, { field: "costcentre", table: tables.costcentres }, { field: "servicecategories", table: tables.servicecategories },
|
||||
{ field: "materialComposition.product", table: tables.products }, { field: "materialComposition.service", table: tables.services },
|
||||
{ field: "personalComposition.hourrate", table: tables.hourrates },
|
||||
],
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "costcentres", namespace: "cost_centres", label: "Kostenstelle", resultKey: "costCentre", table: tables.costcentres,
|
||||
required: ["name"], numberField: "number",
|
||||
fieldNames: "name number parentCostcentre vehicle project branch inventoryitem description profiles",
|
||||
overrides: { number: textField(true), vehicle: idField, project: idField, branch: idField, inventoryitem: idField, profiles: profilesField },
|
||||
references: [commonProfiles, { field: "parentCostcentre", table: tables.costcentres }, { field: "vehicle", table: tables.vehicles }, { field: "project", table: tables.projects }, { field: "branch", table: tables.branches }, { field: "inventoryitem", table: tables.inventoryitems }],
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "branches", namespace: "branches", label: "Niederlassung", resultKey: "branch", table: tables.branches,
|
||||
required: ["name"], numberField: "number", fieldNames: "name number description", overrides: { number: textField(true) }, addGet: true,
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "teams", namespace: "teams", label: "Team", resultKey: "team", table: tables.teams,
|
||||
required: ["name"], fieldNames: "name description branch", overrides: { branch: idField }, references: [{ field: "branch", table: tables.branches }], addGet: true,
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "vehicles", namespace: "vehicles", label: "Fahrzeug", resultKey: "vehicle", table: tables.vehicles,
|
||||
required: ["name"], fieldNames: "name license_plate type active driver vin tank_size build_year towing_capacity power_in_kw color profiles",
|
||||
overrides: { profiles: profilesField, tank_size: numericField(false, 0), towing_capacity: numericField(true, 0), power_in_kw: numericField(true, 0) },
|
||||
references: [commonProfiles, { field: "driver", table: tables.authProfiles, idKey: "user_id", tenantKey: "tenant_id" }], addGet: true,
|
||||
}),
|
||||
makeConfig({
|
||||
resource: "inventoryitems", namespace: "inventory_items", label: "Inventar", resultKey: "inventoryItem", table: tables.inventoryitems,
|
||||
required: ["name"], numberField: "articleNumber",
|
||||
fieldNames: "name usePlanning description currentSpace articleNumber serialNumber purchaseDate vendor quantity purchasePrice manufacturer manufacturerNumber currentValue profiles",
|
||||
overrides: { articleNumber: textField(true), currentSpace: idField, vendor: idField, profiles: profilesField, quantity: numericField(true, 0) },
|
||||
references: [commonProfiles, { field: "currentSpace", table: tables.spaces }, { field: "vendor", table: tables.vendors }],
|
||||
}),
|
||||
]
|
||||
|
||||
const identifier = (config: Config) => config.resource === "costcentres" ? uuidField : idField
|
||||
|
||||
export function prepareMasterdataInput(config: Config, action: "create" | "update" | "archive", args: Record<string, unknown>) {
|
||||
const fields = action === "archive" ? {} : config.fields
|
||||
const input = objectField({ ...fields, ...(action === "create" ? {} : { id: identifier(config) }) }).parse(args, "arguments")
|
||||
for (const key of action === "create" ? config.required : ["id"]) {
|
||||
if (input[key] === undefined || input[key] === null) throw new Error(`${key} ist erforderlich`)
|
||||
}
|
||||
if (action === "update" && Object.keys(input).length === 1) throw new Error("Mindestens ein bearbeitbares Feld ist erforderlich")
|
||||
return input
|
||||
}
|
||||
|
||||
function valuesAtPath(value: any, path: string[]): any[] {
|
||||
if (Array.isArray(value)) return value.flatMap((entry) => valuesAtPath(entry, path))
|
||||
if (!path.length) return value === undefined || value === null ? [] : [value]
|
||||
return valuesAtPath(value?.[path[0]], path.slice(1))
|
||||
}
|
||||
|
||||
async function validateReferences(db: any, config: Config, tenantId: number, data: Record<string, any>) {
|
||||
for (const reference of config.references || []) {
|
||||
const ids = [...new Set(valuesAtPath(data, reference.field.split(".")))]
|
||||
if (!ids.length) continue
|
||||
const table = reference.table
|
||||
const idColumn = table[reference.idKey || "id"]
|
||||
const tenantColumn = table[reference.tenantKey || "tenant"]
|
||||
const conditions = [inArray(idColumn, ids)]
|
||||
if (tenantColumn) conditions.push(eq(tenantColumn, tenantId))
|
||||
if (table.archived) conditions.push(eq(table.archived, false))
|
||||
if (table === tables.authProfiles) conditions.push(eq(table.active, true))
|
||||
const found = await db.select({ id: idColumn }).from(table).where(and(...conditions))
|
||||
if (new Set(found.map((row: any) => row.id)).size !== ids.length) {
|
||||
throw new Error(`${reference.field}: mindestens ein verknüpfter Datensatz ist im aktiven Mandanten nicht verfügbar`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mutate(context: McpContext, config: Config, action: "create" | "update" | "archive", args: Record<string, unknown>) {
|
||||
const { id, ...input } = prepareMasterdataInput(config, action, args)
|
||||
return context.server.db.transaction(async (tx) => {
|
||||
// Coordinate number allocation and hierarchy changes across concurrent MCP calls.
|
||||
// The existing number-range helper uses the same transaction-scoped tenant lock.
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(${context.tenantId})`)
|
||||
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
|
||||
const table = config.table
|
||||
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId), eq(table.archived, false))
|
||||
let existing: any = null
|
||||
if (action !== "create") {
|
||||
;[existing] = await tx.select().from(table).where(scope).limit(1).for("update")
|
||||
if (!existing) throw new Error(`${config.label} nicht gefunden oder archiviert`)
|
||||
}
|
||||
const data: Record<string, any> = action === "archive" ? { archived: true } : { ...input }
|
||||
if (action !== "archive") {
|
||||
if (data.infoData && existing?.infoData) data.infoData = { ...existing.infoData, ...data.infoData }
|
||||
if (config.resource === "contacts" && data.fullName === undefined && (action === "create" || data.firstName !== undefined || data.lastName !== undefined)) {
|
||||
const merged = { ...existing, ...data }
|
||||
data.fullName = [merged.firstName, merged.lastName].filter(Boolean).join(" ")
|
||||
}
|
||||
const merged = { ...existing, ...data }
|
||||
if (config.resource === "contacts" && !merged.fullName?.trim()) throw new Error("Kontakt benötigt fullName oder Vor-/Nachname")
|
||||
if (config.resource === "customers" && merged.type === "Mitglied") {
|
||||
data.isCompany = false
|
||||
if (!merged.firstname?.trim() || !merged.lastname?.trim() || !merged.infoData?.bankAccountIds?.length) {
|
||||
throw new Error("Mitglieder benötigen Vorname, Nachname und mindestens ein Bankkonto")
|
||||
}
|
||||
if (merged.infoData.hasSEPA && !merged.infoData.sepaSignedAt) throw new Error("SEPA-Mandat benötigt ein Unterschriftsdatum")
|
||||
}
|
||||
if (config.resource === "services") {
|
||||
// The shared recalculation service prefers sellingPriceComposed.total.
|
||||
// Keep both representations consistent for manual price edits, including zero.
|
||||
if (data.sellingPrice !== undefined) {
|
||||
const total = data.sellingPrice ?? 0
|
||||
if (data.sellingPriceComposed?.total !== undefined && data.sellingPriceComposed.total !== total) {
|
||||
throw new Error("sellingPrice und sellingPriceComposed.total widersprechen sich")
|
||||
}
|
||||
data.sellingPriceComposed = { ...existing?.sellingPriceComposed, ...data.sellingPriceComposed, total }
|
||||
} else if (data.sellingPriceComposed?.total !== undefined) {
|
||||
data.sellingPrice = data.sellingPriceComposed.total
|
||||
}
|
||||
}
|
||||
await validateReferences(tx, config, context.tenantId, data)
|
||||
if (config.resource === "costcentres") {
|
||||
const error = await validateCostCentreParent(server, context.tenantId, id || null, merged.parentCostcentre || null)
|
||||
if (error) throw new Error(error)
|
||||
}
|
||||
if (config.numberField) {
|
||||
if (action === "create" && !data[config.numberField]) {
|
||||
const result = await useNextNumberRangeNumber(server, context.tenantId, config.resource, async (candidate, numberTx) => {
|
||||
const rows = await numberTx.select({ id: table.id }).from(table)
|
||||
.where(and(eq(table.tenant, context.tenantId), eq(table[config.numberField!], candidate))).limit(1)
|
||||
return rows.length === 0
|
||||
})
|
||||
data[config.numberField] = result.usedNumber
|
||||
} else if (data[config.numberField] !== undefined && data[config.numberField] !== null) {
|
||||
const rows = await tx.select({ id: table.id }).from(table)
|
||||
.where(and(eq(table.tenant, context.tenantId), eq(table[config.numberField], data[config.numberField]))).limit(2)
|
||||
if (rows.some((row: any) => row.id !== id)) throw new Error(`${config.numberField} ist bereits vergeben`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const columns = getTableColumns(table)
|
||||
data["updated_at" in columns ? "updated_at" : "updatedAt"] = new Date()
|
||||
data["updated_by" in columns ? "updated_by" : "updatedBy"] = context.userId
|
||||
let result: any
|
||||
if (action === "create") {
|
||||
;[result] = await tx.insert(table).values({ ...data, tenant: context.tenantId, archived: false }).returning()
|
||||
} else {
|
||||
;[result] = await tx.update(table).set(data).where(scope).returning()
|
||||
}
|
||||
if (["products", "services"].includes(config.resource)) {
|
||||
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
|
||||
;[result] = await tx.select().from(table).where(and(eq(table.id, result.id), eq(table.tenant, context.tenantId))).limit(1)
|
||||
}
|
||||
await insertHistoryItem(server, {
|
||||
tenant_id: context.tenantId, created_by: context.userId, entity: config.resource, entityId: result.id,
|
||||
action: action === "create" ? "created" : action === "archive" ? "archived" : "updated", oldVal: existing, newVal: result,
|
||||
})
|
||||
// Archived objects are filtered from normal MCP results; return an explicit confirmation.
|
||||
return action === "archive" ? { id: result.id, success: true, action: "archived" } : { [config.resultKey]: result }
|
||||
})
|
||||
}
|
||||
|
||||
export const masterdataMutationTools: McpTool[] = masterdataWriteConfigs.flatMap((config) => {
|
||||
const tools: McpTool[] = (["create", "update", "archive"] as const).map((action) => {
|
||||
const fields = action === "archive" ? {} : config.fields
|
||||
const properties = Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.schema]))
|
||||
if (action !== "create") properties.id = identifier(config).schema
|
||||
if (config.numberField && action === "create") properties[config.numberField] = { ...properties[config.numberField], description: "Optional; ohne Angabe Vergabe aus dem FEDEO-Nummernkreis." }
|
||||
const tool: McpTool = {
|
||||
name: `masterdata.${config.namespace}.${action}`,
|
||||
title: `${config.label} ${action === "create" ? "anlegen" : action === "update" ? "bearbeiten" : "archivieren"}`,
|
||||
description: `${config.label} im aktiven Mandanten ${action === "create" ? "anlegen" : action === "update" ? "bearbeiten. Nur übergebene Felder werden geändert; null entfernt nullable Werte, Arrays werden ersetzt" : "archivieren"}. Verknüpfungen werden auf Mandantenzugehörigkeit geprüft.`,
|
||||
requiredPermissions: [`masterdata.${config.namespace}.write`],
|
||||
inputSchema: { type: "object", properties, required: action === "create" ? config.required : ["id"], additionalProperties: false },
|
||||
async handler(context, args) {
|
||||
assertToolPermission(context, tool)
|
||||
return mutate(context, config, action, args)
|
||||
},
|
||||
}
|
||||
return tool
|
||||
})
|
||||
if (config.addGet) {
|
||||
const tool: McpTool = {
|
||||
name: `masterdata.${config.namespace}.get`, title: `${config.label} laden`,
|
||||
description: `Lädt ${config.label} des aktiven Mandanten anhand der ID.`,
|
||||
requiredPermissions: [`masterdata.${config.namespace}.read`],
|
||||
inputSchema: { type: "object", properties: { id: identifier(config).schema }, required: ["id"], additionalProperties: false },
|
||||
async handler(context, args) {
|
||||
assertToolPermission(context, tool)
|
||||
const id = identifier(config).parse(args.id, "id")
|
||||
const table = config.table
|
||||
const [result] = await context.server.db.select().from(table)
|
||||
.where(and(eq(table.id, id), eq(table.tenant, context.tenantId), eq(table.archived, false))).limit(1)
|
||||
if (!result) throw new Error(`${config.label} nicht gefunden oder archiviert`)
|
||||
return { [config.resultKey]: result }
|
||||
},
|
||||
}
|
||||
tools.push(tool)
|
||||
}
|
||||
return tools
|
||||
})
|
||||
@@ -1,14 +1,10 @@
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
||||
import { insertHistoryItem } from "../../utils/history"
|
||||
import { activateProjectPhase } from "../projectPhases"
|
||||
import { McpTool } from "../types"
|
||||
|
||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||
const raw = Number(args.limit ?? fallback)
|
||||
if (!Number.isFinite(raw)) return fallback
|
||||
return Math.min(Math.max(Math.trunc(raw), 1), 100)
|
||||
}
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
const value = args[key]
|
||||
@@ -30,8 +26,7 @@ export const organisationTools: McpTool[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -47,7 +42,7 @@ export const organisationTools: McpTool[] = [
|
||||
ilike(customers.notes, `%${query}%`)
|
||||
))
|
||||
}
|
||||
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
|
||||
conditions.push(eq(customers.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select({
|
||||
@@ -63,10 +58,11 @@ export const organisationTools: McpTool[] = [
|
||||
})
|
||||
.from(customers)
|
||||
.where(and(...conditions))
|
||||
.orderBy(customers.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(customers.name, customers.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -80,8 +76,7 @@ export const organisationTools: McpTool[] = [
|
||||
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
||||
customer: { type: "number" },
|
||||
activePhase: { type: "string" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -100,16 +95,17 @@ export const organisationTools: McpTool[] = [
|
||||
}
|
||||
if (customer) conditions.push(eq(projects.customer, customer))
|
||||
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
||||
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
|
||||
conditions.push(eq(projects.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(projects.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(desc(projects.createdAt), projects.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -131,7 +127,7 @@ export const organisationTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
||||
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId), eq(projects.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
||||
@@ -219,8 +215,7 @@ export const organisationTools: McpTool[] = [
|
||||
properties: {
|
||||
query: { type: "string", description: "Suchtext für Name." },
|
||||
customer: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -230,16 +225,17 @@ export const organisationTools: McpTool[] = [
|
||||
|
||||
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
||||
if (customer) conditions.push(eq(plants.customer, customer))
|
||||
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
|
||||
conditions.push(eq(plants.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(plants)
|
||||
.where(and(...conditions))
|
||||
.orderBy(plants.name)
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(plants.name, plants.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -254,8 +250,7 @@ export const organisationTools: McpTool[] = [
|
||||
project: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
eventtype: { type: "string" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -275,16 +270,17 @@ export const organisationTools: McpTool[] = [
|
||||
if (project) conditions.push(eq(events.project, project))
|
||||
if (customer) conditions.push(eq(events.customer, customer))
|
||||
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
||||
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
|
||||
conditions.push(eq(events.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(events.startDate))
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(desc(events.startDate), events.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -298,8 +294,7 @@ export const organisationTools: McpTool[] = [
|
||||
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
||||
project: { type: "number" },
|
||||
customer: { type: "number" },
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||
...paginationProperties,
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -317,16 +312,17 @@ export const organisationTools: McpTool[] = [
|
||||
}
|
||||
if (project) conditions.push(eq(tasks.project, project))
|
||||
if (customer) conditions.push(eq(tasks.customer, customer))
|
||||
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
|
||||
conditions.push(eq(tasks.archived, false))
|
||||
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(tasks.createdAt))
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(desc(tasks.createdAt), tasks.id)
|
||||
.limit(limitFromArgs(args) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -348,7 +344,7 @@ export const organisationTools: McpTool[] = [
|
||||
const rows = await context.server.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId), eq(tasks.archived, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||
import { and, asc, eq, ilike, isNull } from "drizzle-orm"
|
||||
import { wikiPages } from "../../../db/schema"
|
||||
import { McpContext, McpTool } from "../types"
|
||||
@@ -27,17 +28,7 @@ const pageIdArg = (args: Record<string, unknown>, key = "id") => {
|
||||
return value
|
||||
}
|
||||
|
||||
const limitFromArgs = (args: Record<string, unknown>) => {
|
||||
const value = Number(args.limit ?? 50)
|
||||
if (!Number.isFinite(value)) return 50
|
||||
return Math.min(Math.max(Math.trunc(value), 1), 100)
|
||||
}
|
||||
|
||||
const offsetFromArgs = (args: Record<string, unknown>) => {
|
||||
const value = Number(args.offset ?? 0)
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(Math.trunc(value), 0)
|
||||
}
|
||||
|
||||
export function prepareWikiScope(args: Record<string, unknown>): WikiScope {
|
||||
const entityType = stringArg(args, "entityType")
|
||||
@@ -144,8 +135,8 @@ export const wikiTools: McpTool[] = [
|
||||
...entityScopeProperties,
|
||||
query: { type: "string", description: "Optionaler Suchtext im Titel." },
|
||||
parentId: { type: ["string", "null"], format: "uuid", description: "Optional auf direkte Kinder dieses Ordners einschränken; null steht für die oberste Ebene." },
|
||||
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
||||
offset: { type: "integer", minimum: 0, default: 0 },
|
||||
...paginationProperties,
|
||||
limit: { ...paginationProperties.limit, default: 50 },
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
@@ -173,11 +164,11 @@ export const wikiTools: McpTool[] = [
|
||||
})
|
||||
.from(wikiPages)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title))
|
||||
.limit(limitFromArgs(args))
|
||||
.orderBy(asc(wikiPages.sortOrder), asc(wikiPages.title), wikiPages.id)
|
||||
.limit(limitFromArgs(args, 50) + 1)
|
||||
.offset(offsetFromArgs(args))
|
||||
|
||||
return { rows }
|
||||
return paginatedResult(rows, args, 50)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
30
backend/src/modules/bank-allocation-details.ts
Normal file
30
backend/src/modules/bank-allocation-details.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { costcentres } from "../../db/schema"
|
||||
|
||||
const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 })
|
||||
|
||||
export function normalizeBankAllocationDetails(payload: Record<string, any>) {
|
||||
const costcentre = payload.costcentre ? String(payload.costcentre).trim() : null
|
||||
const datevTaxKey = payload.datevTaxKey ? String(payload.datevTaxKey).trim() : null
|
||||
if (costcentre && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(costcentre)) {
|
||||
throw invalidInput("Die Kostenstelle muss eine gültige UUID sein.")
|
||||
}
|
||||
if (datevTaxKey && !/^\d{1,4}$/.test(datevTaxKey)) {
|
||||
throw invalidInput("Der DATEV-Steuerschlüssel muss aus 1 bis 4 Ziffern bestehen.")
|
||||
}
|
||||
if (costcentre && (!payload.bankstatement || !(payload.account || payload.ownaccount)
|
||||
|| payload.createddocument || payload.incominginvoice || payload.customer || payload.vendor)) {
|
||||
throw invalidInput("Kostenstellen sind hier nur für direkte Bankbuchungen auf Buchungskonten zulässig.")
|
||||
}
|
||||
return { costcentre, datevTaxKey }
|
||||
}
|
||||
|
||||
export async function validateBankAllocationCostCentre(db: any, tenantId: number, costcentre: string | null) {
|
||||
if (!costcentre) return
|
||||
const [record] = await db.select({ id: costcentres.id }).from(costcentres).where(and(
|
||||
eq(costcentres.id, costcentre),
|
||||
eq(costcentres.tenant, tenantId),
|
||||
eq(costcentres.archived, false),
|
||||
)).limit(1)
|
||||
if (!record) throw invalidInput("Die Kostenstelle wurde nicht gefunden oder ist archiviert.")
|
||||
}
|
||||
@@ -19,21 +19,30 @@ import {
|
||||
tenants,
|
||||
texttemplates,
|
||||
} from "../../db/schema"
|
||||
import { matrixService } from "./matrix.service"
|
||||
|
||||
const adminPermissions = [
|
||||
"mcp.tokens.write",
|
||||
"staff.time.read_all",
|
||||
"masterdata.customers.read",
|
||||
"masterdata.customers.write",
|
||||
"masterdata.vendors.read",
|
||||
"masterdata.vendors.write",
|
||||
"masterdata.contacts.read",
|
||||
"masterdata.contacts.write",
|
||||
"masterdata.products.read",
|
||||
"masterdata.products.write",
|
||||
"masterdata.services.read",
|
||||
"masterdata.services.write",
|
||||
"masterdata.cost_centres.read",
|
||||
"masterdata.cost_centres.write",
|
||||
"masterdata.branches.read",
|
||||
"masterdata.branches.write",
|
||||
"masterdata.teams.read",
|
||||
"masterdata.teams.write",
|
||||
"masterdata.vehicles.read",
|
||||
"masterdata.vehicles.write",
|
||||
"masterdata.inventory_items.read",
|
||||
"masterdata.inventory_items.write",
|
||||
"masterdata.units.read",
|
||||
"accounting.outgoing_documents.read",
|
||||
"accounting.outgoing_documents.write",
|
||||
@@ -456,18 +465,4 @@ export async function runBootstrap(server: FastifyInstance) {
|
||||
await ensureTenantBaseData(server, tenant.id, adminUser.id)
|
||||
console.log("✅ Bootstrap-Grunddaten geprüft")
|
||||
|
||||
if (process.env.FEDEO_BOOTSTRAP_MATRIX === "true") {
|
||||
try {
|
||||
const matrix = matrixService(server)
|
||||
await matrix.provisionTenantRoom(adminUser.id, tenant.id, {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
type: "general",
|
||||
})
|
||||
console.log("✅ Bootstrap-Matrix-Kommunikation geprüft")
|
||||
} catch (err) {
|
||||
console.error("❌ Bootstrap-Matrix-Kommunikation fehlgeschlagen:", err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"
|
||||
|
||||
import { centralServicesClient } from "./push-server.client"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { getBuildInfo } from "../utils/buildInfo"
|
||||
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -31,7 +32,7 @@ export function startCentralServicesHeartbeat(server: FastifyInstance) {
|
||||
const sendHeartbeat = async () => {
|
||||
try {
|
||||
const response = await centralServicesClient.heartbeat({
|
||||
fedeoVersion: secrets.FEDEO_VERSION || "selfhost",
|
||||
fedeoVersion: getBuildInfo().version,
|
||||
baseUrl: instanceBaseUrl(),
|
||||
})
|
||||
console.log(`✅ Central Services Heartbeat: ${response.instanceId} (${response.status})`)
|
||||
|
||||
56
backend/src/modules/cost-centre-hierarchy.service.ts
Normal file
56
backend/src/modules/cost-centre-hierarchy.service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { costcentres } from "../../db/schema"
|
||||
|
||||
export async function validateCostCentreParent(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
costCentreId: string | null,
|
||||
parentCostcentreId: string | null
|
||||
) {
|
||||
if (!parentCostcentreId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hierarchyRows = await server.db
|
||||
.select({
|
||||
id: costcentres.id,
|
||||
parentCostcentre: costcentres.parentCostcentre,
|
||||
})
|
||||
.from(costcentres)
|
||||
.where(eq(costcentres.tenant, tenantId))
|
||||
|
||||
const hierarchyMap = new Map(
|
||||
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
|
||||
)
|
||||
|
||||
if (!hierarchyMap.has(parentCostcentreId)) {
|
||||
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
|
||||
}
|
||||
|
||||
if (costCentreId && parentCostcentreId === costCentreId) {
|
||||
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
|
||||
}
|
||||
|
||||
if (!costCentreId) {
|
||||
return null
|
||||
}
|
||||
|
||||
let currentParentId: string | null = parentCostcentreId
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (currentParentId === costCentreId) {
|
||||
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
|
||||
}
|
||||
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
currentParentId = hierarchyMap.get(currentParentId) || null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { and, desc, eq, inArray, isNotNull, ne } from "drizzle-orm"
|
||||
import { authProfiles, authTenantUsers, authUsers, communicationRooms, notificationsItems } from "../../db/schema"
|
||||
import { matrixService } from "./matrix.service"
|
||||
import { NotificationService, UserDirectory } from "./notification.service"
|
||||
|
||||
type ChatRecipient = {
|
||||
userId: string
|
||||
email?: string | null
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
fullName?: string | null
|
||||
matrixUserId?: string
|
||||
}
|
||||
|
||||
type MatrixPushWorkerEvent = {
|
||||
at: string
|
||||
type: string
|
||||
roomKey?: string
|
||||
roomId?: string | null
|
||||
messageId?: string
|
||||
sender?: string
|
||||
targets?: number
|
||||
created?: number
|
||||
delivered?: number
|
||||
failed?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const matrixPushWorkerState = {
|
||||
enabled: false,
|
||||
startedAt: null as string | null,
|
||||
lastRunAt: null as string | null,
|
||||
lastJoinAt: null as string | null,
|
||||
lastJoinTotal: 0,
|
||||
lastJoinJoined: 0,
|
||||
lastJoinFailed: 0,
|
||||
hasSyncToken: false,
|
||||
lastSyncRooms: 0,
|
||||
lastSyncMessages: 0,
|
||||
lastMatchedRooms: 0,
|
||||
lastNotificationsCreated: 0,
|
||||
lastNotificationsDelivered: 0,
|
||||
lastNotificationsFailed: 0,
|
||||
lastError: null as string | null,
|
||||
events: [] as MatrixPushWorkerEvent[],
|
||||
}
|
||||
|
||||
const rememberWorkerEvent = (event: MatrixPushWorkerEvent) => {
|
||||
matrixPushWorkerState.events = [
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
...event,
|
||||
},
|
||||
...matrixPushWorkerState.events,
|
||||
].slice(0, 25)
|
||||
}
|
||||
|
||||
export const getMatrixPushWorkerState = () => ({
|
||||
...matrixPushWorkerState,
|
||||
events: [...matrixPushWorkerState.events],
|
||||
})
|
||||
|
||||
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||
const rows = await server.db
|
||||
.select({ email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => {
|
||||
const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ")
|
||||
return name || user.email || "Benutzer"
|
||||
}
|
||||
|
||||
const directRoomKey = (firstUserId: string, secondUserId: string) => {
|
||||
const hash = createHash("sha256")
|
||||
.update([firstUserId, secondUserId].sort().join(":"))
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
|
||||
return `direct_${hash}`
|
||||
}
|
||||
|
||||
const mentionAliasesForUser = (user: ChatRecipient) => {
|
||||
const name = displayUserName(user)
|
||||
return Array.from(new Set([
|
||||
name,
|
||||
user.fullName,
|
||||
[user.firstName, user.lastName].filter(Boolean).join(" "),
|
||||
user.firstName,
|
||||
user.email,
|
||||
].filter(Boolean).map((value) => String(value).toLowerCase())))
|
||||
}
|
||||
|
||||
const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => {
|
||||
const normalizedText = text.toLowerCase()
|
||||
|
||||
return recipients
|
||||
.filter((recipient) => mentionAliasesForUser(recipient).some((alias) =>
|
||||
normalizedText.includes(`@${alias}`)
|
||||
))
|
||||
.map((recipient) => recipient.userId)
|
||||
}
|
||||
|
||||
export function startMatrixPushWorker(server: FastifyInstance) {
|
||||
if (process.env.MATRIX_PUSH_WORKER_DISABLED === "1") {
|
||||
server.log.info("Matrix-Push-Worker ist deaktiviert")
|
||||
return
|
||||
}
|
||||
|
||||
matrixPushWorkerState.enabled = true
|
||||
matrixPushWorkerState.startedAt = new Date().toISOString()
|
||||
rememberWorkerEvent({ at: new Date().toISOString(), type: "started" })
|
||||
|
||||
const matrix = matrixService(server)
|
||||
const notifications = new NotificationService(server, getUserDirectory)
|
||||
const intervalMs = Math.max(Number(process.env.MATRIX_PUSH_WORKER_INTERVAL_MS || 3000), 1000)
|
||||
let since: string | undefined
|
||||
let running = false
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastServiceJoinSyncAt = 0
|
||||
let errorBackoffMs = 0
|
||||
|
||||
const getTenantRecipients = async (tenantId: number) => {
|
||||
const rows = await server.db
|
||||
.select({
|
||||
userId: authTenantUsers.user_id,
|
||||
email: authUsers.email,
|
||||
firstName: authProfiles.first_name,
|
||||
lastName: authProfiles.last_name,
|
||||
fullName: authProfiles.full_name,
|
||||
})
|
||||
.from(authTenantUsers)
|
||||
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
|
||||
.leftJoin(authProfiles, and(
|
||||
eq(authProfiles.user_id, authTenantUsers.user_id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||
|
||||
return await Promise.all(rows.map(async (row) => ({
|
||||
...row,
|
||||
matrixUserId: await matrix.matrixUserIdForUser(row.userId, tenantId),
|
||||
})))
|
||||
}
|
||||
|
||||
const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => {
|
||||
const rows = await server.db
|
||||
.select({
|
||||
payload: notificationsItems.payload,
|
||||
})
|
||||
.from(notificationsItems)
|
||||
.where(and(
|
||||
eq(notificationsItems.tenantId, tenantId),
|
||||
eq(notificationsItems.userId, userId),
|
||||
eq(notificationsItems.eventType, "communication.message.new")
|
||||
))
|
||||
.orderBy(desc(notificationsItems.createdAt))
|
||||
.limit(200)
|
||||
|
||||
return rows.some((row) => (row.payload as any)?.messageId === messageId)
|
||||
}
|
||||
|
||||
const recipientsForMessage = (
|
||||
room: typeof communicationRooms.$inferSelect,
|
||||
recipients: ChatRecipient[],
|
||||
senderUserId: string | null,
|
||||
text: string
|
||||
) => {
|
||||
const candidates = senderUserId
|
||||
? recipients.filter((recipient) => recipient.userId !== senderUserId)
|
||||
: recipients
|
||||
const mentioned = new Set(mentionedRecipientIds(text, candidates))
|
||||
const directRecipients = new Set<string>()
|
||||
|
||||
if (room.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
|
||||
directRecipients.add(room.entityUuid)
|
||||
} else if (room.type === "direct" && senderUserId) {
|
||||
candidates
|
||||
.filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key)
|
||||
.forEach((recipient) => directRecipients.add(recipient.userId))
|
||||
}
|
||||
|
||||
return candidates
|
||||
.filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId))
|
||||
.map((recipient) => ({
|
||||
...recipient,
|
||||
mentioned: mentioned.has(recipient.userId),
|
||||
direct: directRecipients.has(recipient.userId),
|
||||
}))
|
||||
}
|
||||
|
||||
const deliverMessageNotification = async (
|
||||
room: typeof communicationRooms.$inferSelect,
|
||||
message: any,
|
||||
recipients: ChatRecipient[]
|
||||
) => {
|
||||
if (!message.id || message.own) return
|
||||
|
||||
const sender = recipients.find((recipient) => recipient.matrixUserId === message.sender) || null
|
||||
const text = message.body || message.attachment?.fileName || "Neue Nachricht"
|
||||
const targets = recipientsForMessage(room, recipients, sender?.userId || null, text)
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "message_seen",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: targets.length,
|
||||
})
|
||||
if (!targets.length) return
|
||||
|
||||
const senderName = sender ? displayUserName(sender) : message.senderDisplayName || message.sender || "Matrix"
|
||||
const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text
|
||||
|
||||
for (const target of targets) {
|
||||
if (await hasChatNotificationForMessage(room.tenantId, target.userId, message.id)) {
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "notification_skipped_duplicate",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: 1,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await notifications.trigger({
|
||||
tenantId: room.tenantId,
|
||||
userId: target.userId,
|
||||
eventType: "communication.message.new",
|
||||
title: target.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`,
|
||||
message: preview,
|
||||
payload: {
|
||||
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
|
||||
roomKey: room.key,
|
||||
roomName: room.name,
|
||||
roomType: room.type,
|
||||
messageId: message.id,
|
||||
matrixSender: message.sender,
|
||||
mentioned: target.mentioned,
|
||||
direct: target.direct,
|
||||
},
|
||||
channels: ["inapp", "push"],
|
||||
})
|
||||
matrixPushWorkerState.lastNotificationsCreated += result.created || 0
|
||||
matrixPushWorkerState.lastNotificationsDelivered += result.delivered || 0
|
||||
matrixPushWorkerState.lastNotificationsFailed += result.failed || 0
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "notification_triggered",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: 1,
|
||||
created: result.created || 0,
|
||||
delivered: result.delivered || 0,
|
||||
failed: result.failed || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const runOnce = async () => {
|
||||
if (running || stopped) return
|
||||
running = true
|
||||
|
||||
try {
|
||||
matrixPushWorkerState.lastRunAt = new Date().toISOString()
|
||||
matrixPushWorkerState.lastError = null
|
||||
matrixPushWorkerState.lastSyncRooms = 0
|
||||
matrixPushWorkerState.lastSyncMessages = 0
|
||||
matrixPushWorkerState.lastMatchedRooms = 0
|
||||
matrixPushWorkerState.lastNotificationsCreated = 0
|
||||
matrixPushWorkerState.lastNotificationsDelivered = 0
|
||||
matrixPushWorkerState.lastNotificationsFailed = 0
|
||||
|
||||
if (!lastServiceJoinSyncAt || Date.now() - lastServiceJoinSyncAt > 60_000) {
|
||||
const joinResult = await matrix.syncServiceJoinedTenantRooms()
|
||||
lastServiceJoinSyncAt = Date.now()
|
||||
matrixPushWorkerState.lastJoinAt = new Date().toISOString()
|
||||
matrixPushWorkerState.lastJoinTotal = joinResult.total
|
||||
matrixPushWorkerState.lastJoinJoined = joinResult.joined
|
||||
matrixPushWorkerState.lastJoinFailed = joinResult.failed
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "service_join_sync",
|
||||
targets: joinResult.total,
|
||||
delivered: joinResult.joined,
|
||||
failed: joinResult.failed,
|
||||
})
|
||||
if (joinResult.failed) {
|
||||
console.warn("Matrix-Push-Worker: Service-User konnte nicht alle Räume joinen", {
|
||||
total: joinResult.total,
|
||||
joined: joinResult.joined,
|
||||
failed: joinResult.failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const initial = !since
|
||||
const sync = await matrix.syncServiceRoomEvents(since, initial)
|
||||
since = sync.nextBatch || since
|
||||
matrixPushWorkerState.hasSyncToken = Boolean(since)
|
||||
matrixPushWorkerState.lastSyncRooms = sync.rooms?.length || 0
|
||||
matrixPushWorkerState.lastSyncMessages = (sync.rooms || [])
|
||||
.reduce((sum: number, room: any) => sum + (room.messages?.length || 0), 0)
|
||||
|
||||
if (!initial && sync.rooms?.length) {
|
||||
const roomIds = sync.rooms.map((room: any) => room.roomId).filter(Boolean)
|
||||
const rooms = roomIds.length
|
||||
? await server.db
|
||||
.select()
|
||||
.from(communicationRooms)
|
||||
.where(and(
|
||||
inArray(communicationRooms.matrixRoomId, roomIds),
|
||||
ne(communicationRooms.archived, true),
|
||||
isNotNull(communicationRooms.matrixRoomId)
|
||||
))
|
||||
: []
|
||||
const roomsByMatrixId = new Map(rooms.map((room) => [room.matrixRoomId, room]))
|
||||
matrixPushWorkerState.lastMatchedRooms = rooms.length
|
||||
const recipientsByTenant = new Map<number, ChatRecipient[]>()
|
||||
|
||||
for (const syncedRoom of sync.rooms) {
|
||||
const room = roomsByMatrixId.get(syncedRoom.roomId)
|
||||
if (!room || !syncedRoom.messages?.length) continue
|
||||
|
||||
if (!recipientsByTenant.has(room.tenantId)) {
|
||||
recipientsByTenant.set(room.tenantId, await getTenantRecipients(room.tenantId))
|
||||
}
|
||||
|
||||
const recipients = recipientsByTenant.get(room.tenantId) || []
|
||||
for (const message of syncedRoom.messages) {
|
||||
await deliverMessageNotification(room, message, recipients)
|
||||
}
|
||||
}
|
||||
}
|
||||
errorBackoffMs = 0
|
||||
} catch (err) {
|
||||
matrixPushWorkerState.lastError = err instanceof Error ? err.message : String(err)
|
||||
const retryAfterMs = Number((err as any)?.retryAfterMs || (err as any)?.body?.retry_after_ms || 0)
|
||||
errorBackoffMs = Math.min(
|
||||
Math.max(retryAfterMs || (errorBackoffMs ? errorBackoffMs * 2 : 30_000), 30_000),
|
||||
5 * 60_000
|
||||
)
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "error",
|
||||
error: matrixPushWorkerState.lastError,
|
||||
})
|
||||
console.error("Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten", err)
|
||||
server.log.error({ err }, "Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten")
|
||||
} finally {
|
||||
running = false
|
||||
if (!stopped) {
|
||||
const nextDelay = errorBackoffMs || (since ? 0 : intervalMs)
|
||||
timer = setTimeout(() => void runOnce(), nextDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(() => void runOnce(), intervalMs)
|
||||
server.addHook("onClose", async () => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { costcentres, createddocuments } from "../../db/schema"
|
||||
import { insertHistoryItem } from "../utils/history"
|
||||
|
||||
export type RowCostCentreAssignment = {
|
||||
rowId: string
|
||||
costcentre: string | null
|
||||
}
|
||||
|
||||
export type OutgoingDocumentCostCentreUpdate = {
|
||||
costcentre?: string | null
|
||||
rowCostCentres?: RowCostCentreAssignment[]
|
||||
}
|
||||
|
||||
const serviceError = (message: string, statusCode = 400) =>
|
||||
Object.assign(new Error(message), { statusCode })
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
export function applyRowCostCentreAssignments(
|
||||
rows: unknown,
|
||||
assignments: RowCostCentreAssignment[] = [],
|
||||
) {
|
||||
if (!Array.isArray(rows)) return []
|
||||
if (!assignments.length) return rows
|
||||
|
||||
const assignmentMap = new Map(assignments.map((assignment) => [String(assignment.rowId), assignment.costcentre]))
|
||||
const existingIds = new Set(rows
|
||||
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
||||
.map((row) => (row as Record<string, unknown>).id)
|
||||
.filter((id) => id !== null && id !== undefined && id !== "")
|
||||
.map(String))
|
||||
|
||||
for (const rowId of assignmentMap.keys()) {
|
||||
if (!existingIds.has(rowId)) throw serviceError(`Position ${rowId} wurde im Ausgangsbeleg nicht gefunden`)
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) return row
|
||||
const rowRecord = row as Record<string, unknown>
|
||||
if (rowRecord.id === null || rowRecord.id === undefined || rowRecord.id === "") return row
|
||||
const rowId = String(rowRecord.id)
|
||||
if (!assignmentMap.has(rowId)) return row
|
||||
|
||||
const { costcentre: _legacyCostCentre, ...rest } = rowRecord
|
||||
return { ...rest, costCentre: assignmentMap.get(rowId) ?? null }
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateOutgoingDocumentCostCentres(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
userId: string,
|
||||
documentId: number,
|
||||
input: OutgoingDocumentCostCentreUpdate,
|
||||
) {
|
||||
const hasDocumentCostCentre = Object.prototype.hasOwnProperty.call(input, "costcentre")
|
||||
if (hasDocumentCostCentre && input.costcentre !== null && !UUID_PATTERN.test(String(input.costcentre))) {
|
||||
throw serviceError("costcentre muss eine gültige UUID oder null sein")
|
||||
}
|
||||
if (input.rowCostCentres !== undefined && !Array.isArray(input.rowCostCentres)) {
|
||||
throw serviceError("rowCostCentres muss ein Array sein")
|
||||
}
|
||||
|
||||
const rowAssignments = (input.rowCostCentres || []).map((assignment) => {
|
||||
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
|
||||
const costcentre = assignment?.costcentre
|
||||
if (!rowId) throw serviceError("Jede Positionszuordnung benötigt eine rowId")
|
||||
if (costcentre !== null && !UUID_PATTERN.test(String(costcentre))) {
|
||||
throw serviceError(`Ungültige Kostenstelle für Position ${rowId}`)
|
||||
}
|
||||
return { rowId, costcentre }
|
||||
})
|
||||
if (!hasDocumentCostCentre && !rowAssignments.length) {
|
||||
throw serviceError("Mindestens eine Beleg- oder Positionskostenstelle ist erforderlich")
|
||||
}
|
||||
|
||||
const [existing] = await server.db
|
||||
.select()
|
||||
.from(createddocuments)
|
||||
.where(and(
|
||||
eq(createddocuments.id, documentId),
|
||||
eq(createddocuments.tenant, tenantId),
|
||||
eq(createddocuments.archived, false),
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
|
||||
if (existing.state !== "Gebucht") {
|
||||
throw serviceError("Kostenstellen können über diesen Weg nur bei fertiggestellten Ausgangsbelegen geändert werden")
|
||||
}
|
||||
|
||||
const referencedCostCentreIds = [...new Set([
|
||||
...(hasDocumentCostCentre && input.costcentre ? [input.costcentre] : []),
|
||||
...rowAssignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
|
||||
])]
|
||||
|
||||
if (referencedCostCentreIds.length) {
|
||||
const validCostCentres = await server.db
|
||||
.select({ id: costcentres.id })
|
||||
.from(costcentres)
|
||||
.where(and(
|
||||
eq(costcentres.tenant, tenantId),
|
||||
eq(costcentres.archived, false),
|
||||
inArray(costcentres.id, referencedCostCentreIds),
|
||||
))
|
||||
|
||||
if (validCostCentres.length !== referencedCostCentreIds.length) {
|
||||
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRows = applyRowCostCentreAssignments(existing.rows, rowAssignments)
|
||||
const update: Record<string, unknown> = {
|
||||
rows: updatedRows,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
}
|
||||
if (hasDocumentCostCentre) update.costcentre = input.costcentre ?? null
|
||||
|
||||
const [updated] = await server.db
|
||||
.update(createddocuments)
|
||||
.set(update)
|
||||
.where(and(
|
||||
eq(createddocuments.id, documentId),
|
||||
eq(createddocuments.tenant, tenantId),
|
||||
eq(createddocuments.archived, false),
|
||||
eq(createddocuments.state, "Gebucht"),
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (!updated) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
tenant_id: tenantId,
|
||||
created_by: userId,
|
||||
entity: "createddocuments",
|
||||
entityId: documentId,
|
||||
action: "updated",
|
||||
oldVal: existing,
|
||||
newVal: updated,
|
||||
text: "Kostenstellen des fertiggestellten Ausgangsbelegs geändert",
|
||||
})
|
||||
|
||||
return updated
|
||||
}
|
||||
91
backend/src/modules/selfhost-updater.ts
Normal file
91
backend/src/modules/selfhost-updater.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { request } from "node:http";
|
||||
import { z } from "zod";
|
||||
|
||||
const version = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
|
||||
export const requestId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/);
|
||||
export const checkBody = z.object({ requestId, version: version.optional() }).strict();
|
||||
export const applyBody = z.object({ requestId, version, checkRequestId: requestId }).strict();
|
||||
const errors = {
|
||||
INVALID_REQUEST: "Ungültige Anfrage.", NOT_FOUND: "Auftrag nicht gefunden.",
|
||||
BUSY: "Eine Prüfung oder ein Update läuft bereits.", SHUTTING_DOWN: "Der Update-Dienst wird beendet.",
|
||||
REQUEST_CONFLICT: "Diese Auftrags-ID wurde bereits für eine andere Anfrage verwendet.",
|
||||
CHECK_REQUIRED: "Bitte die Zielversion erneut prüfen.",
|
||||
COMPOSE_REVIEW_REQUIRED: "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
|
||||
RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
|
||||
PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.",
|
||||
UPDATE_FAILED: "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
|
||||
INTERRUPTED: "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
|
||||
UPDATER_UNAVAILABLE: "Der Host-Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung prüfen.",
|
||||
UPDATER_PROTOCOL: "Der Host-Update-Dienst hat eine ungültige Antwort geliefert.",
|
||||
};
|
||||
type ErrorCode = keyof typeof errors;
|
||||
const knownError = (code: unknown): code is ErrorCode => typeof code === "string" && Object.prototype.hasOwnProperty.call(errors, code);
|
||||
export class UpdaterError extends Error {
|
||||
constructor(public code: ErrorCode, public status = 503) { super(errors[code]); }
|
||||
}
|
||||
const state = z.object({
|
||||
status: z.enum(["idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"]),
|
||||
phase: z.enum(["preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying",
|
||||
"retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"]).optional(),
|
||||
command: z.enum(["check", "apply"]).optional(), requestId: requestId.optional(),
|
||||
version: version.optional(), previousVersion: version.optional(), revision: z.string().regex(/^[a-f0-9]{40}$/).optional(),
|
||||
recoveryRequired: z.boolean().optional(), maintenance: z.boolean().optional(), mutationStarted: z.boolean().optional(),
|
||||
startedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(), updatedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(),
|
||||
});
|
||||
const plan = state.omit({ status: true }).extend({
|
||||
updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(),
|
||||
estimatedBackupBytes: z.number().nonnegative().finite().optional(),
|
||||
retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
|
||||
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])),
|
||||
});
|
||||
const job = state.extend({ plan: plan.optional(), progress: state.optional(),
|
||||
error: z.object({ code: z.string() }).transform(({ code }) => ({
|
||||
code: knownError(code) ? code : "UPDATE_FAILED",
|
||||
message: knownError(code) ? errors[code] : errors.UPDATE_FAILED,
|
||||
})).optional(),
|
||||
});
|
||||
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
|
||||
installed: state.pick({ version: true, revision: true }), request: job.optional(),
|
||||
});
|
||||
|
||||
// No caller-controlled host, URL, socket, headers or command can reach the privileged service.
|
||||
export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) {
|
||||
return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
|
||||
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(path)))
|
||||
|| (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
|
||||
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = request({ socketPath, path, method, agent: false, headers: payload ? {
|
||||
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload),
|
||||
} : {} }, res => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
res.on("data", chunk => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 65536) req.destroy(new UpdaterError("UPDATER_PROTOCOL", 502));
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
res.on("error", () => reject(new UpdaterError("UPDATER_UNAVAILABLE")));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
const code = data?.error?.code;
|
||||
if (![200, 202].includes(res.statusCode || 0)) {
|
||||
throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!)
|
||||
? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502);
|
||||
}
|
||||
resolve({ status: res.statusCode!, body: (path === "/v1/status" ? status : job).parse(data) });
|
||||
} catch (error) {
|
||||
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502));
|
||||
}
|
||||
});
|
||||
});
|
||||
const timer = setTimeout(() => req.destroy(new UpdaterError("UPDATER_UNAVAILABLE")), timeoutMs);
|
||||
req.on("close", () => clearTimeout(timer));
|
||||
req.on("error", error => reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE")));
|
||||
req.end(payload);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -14,9 +14,13 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
|
||||
dayjs.extend(quarterOfYear);
|
||||
|
||||
|
||||
export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => {
|
||||
try {
|
||||
console.log(executedBy)
|
||||
export const executeManualGeneration = async (
|
||||
server: FastifyInstance,
|
||||
executionDate: string | Date,
|
||||
templateIds: number[],
|
||||
tenantId: number,
|
||||
executedBy: string,
|
||||
) => {
|
||||
|
||||
const executionDayjs = dayjs(executionDate);
|
||||
|
||||
@@ -33,20 +37,22 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
||||
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
|
||||
|
||||
// 2. Templates laden
|
||||
const templates = await server.db
|
||||
const uniqueTemplateIds = [...new Set(templateIds)]
|
||||
const templates = (await server.db
|
||||
.select()
|
||||
.from(schema.createddocuments)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.createddocuments.tenant, tenantId),
|
||||
eq(schema.createddocuments.type, "serialInvoices"),
|
||||
inArray(schema.createddocuments.id, templateIds)
|
||||
eq(schema.createddocuments.archived, false),
|
||||
inArray(schema.createddocuments.id, uniqueTemplateIds)
|
||||
)
|
||||
);
|
||||
))
|
||||
.filter((template) => Boolean((template.serialConfig as any)?.active));
|
||||
|
||||
if (templates.length === 0) {
|
||||
console.warn("Keine passenden Vorlagen gefunden.");
|
||||
return [];
|
||||
if (templates.length !== uniqueTemplateIds.length) {
|
||||
throw new Error("Mindestens eine Serienrechnungsvorlage wurde nicht gefunden, ist archiviert oder inaktiv.");
|
||||
}
|
||||
|
||||
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
|
||||
@@ -62,7 +68,7 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
||||
executionDate: executionDayjs.toDate(),
|
||||
status: "draft",
|
||||
createdBy: executedBy,
|
||||
summary: `${templateIds.length} Vorlagen verarbeitet`
|
||||
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -88,13 +94,10 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
return { execution: executionRecord, results };
|
||||
}
|
||||
|
||||
export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => {
|
||||
export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
|
||||
try {
|
||||
console.log(`Beende Ausführung ${executionId}...`);
|
||||
|
||||
@@ -103,15 +106,16 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
||||
const [executionRecord] = await server.db
|
||||
.select()
|
||||
.from(schema.serialExecutions)// @ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId))
|
||||
.where(and(
|
||||
eq(schema.serialExecutions.id, executionId),
|
||||
eq(schema.serialExecutions.tenant, tenantId),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!executionRecord) throw new Error("Execution nicht gefunden");
|
||||
|
||||
console.log(executionRecord);
|
||||
|
||||
const tenantId = executionRecord.tenant;
|
||||
|
||||
console.log(tenantId)
|
||||
|
||||
// Tenant laden (für Settings etc.)
|
||||
@@ -132,7 +136,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
||||
const documents = await server.db
|
||||
.select()
|
||||
.from(schema.createddocuments)
|
||||
.where(eq(schema.createddocuments.serialexecution, executionId));
|
||||
.where(and(
|
||||
eq(schema.createddocuments.serialexecution, executionId),
|
||||
eq(schema.createddocuments.tenant, tenantId),
|
||||
eq(schema.createddocuments.archived, false),
|
||||
));
|
||||
|
||||
console.log(`${documents.length} Dokumente werden finalisiert...`);
|
||||
|
||||
@@ -228,7 +236,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
||||
status: finalStatus,
|
||||
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
|
||||
})// @ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId));
|
||||
.where(and(
|
||||
eq(schema.serialExecutions.id, executionId),
|
||||
eq(schema.serialExecutions.tenant, tenantId),
|
||||
));
|
||||
|
||||
return { success: true, processed: successCount, errors: errorCount };
|
||||
|
||||
@@ -240,7 +251,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
||||
.update(schema.serialExecutions)
|
||||
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
|
||||
//@ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId));
|
||||
.where(and(
|
||||
eq(schema.serialExecutions.id, executionId),
|
||||
eq(schema.serialExecutions.tenant, tenantId),
|
||||
));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { matrixService } from "./matrix.service"
|
||||
|
||||
type MetricSample = {
|
||||
labels: Record<string, string>
|
||||
@@ -117,10 +116,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
|
||||
|
||||
const databaseCheck = await server.db.execute("SELECT NOW() as now")
|
||||
const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({
|
||||
reachable: false,
|
||||
error: err?.message || "Matrix-Status nicht verfügbar",
|
||||
}))
|
||||
const minioUrl = s3EndpointUrl()
|
||||
|
||||
return {
|
||||
@@ -165,7 +160,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
url: nodeExporterMetricsUrl,
|
||||
error: nodeExporterError,
|
||||
}),
|
||||
matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record<string, any>),
|
||||
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
|
||||
error: "S3_ENDPOINT ist nicht gesetzt",
|
||||
}),
|
||||
|
||||
@@ -84,10 +84,12 @@ export default fp(async (server: FastifyInstance) => {
|
||||
|
||||
const urlPath = req.url.split("?")[0]
|
||||
const queryToken = (req.query as any)?.downloadToken
|
||||
const isDownloadTokenRoute =
|
||||
(urlPath.startsWith("/api/email/attachments/") && urlPath.endsWith("/download"))
|
||||
|| urlPath.startsWith("/api/files/content/")
|
||||
const downloadToken =
|
||||
typeof queryToken === "string"
|
||||
&& urlPath.startsWith("/api/email/attachments/")
|
||||
&& urlPath.endsWith("/download")
|
||||
&& isDownloadTokenRoute
|
||||
? queryToken
|
||||
: null
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
importTenantFullExportArchive,
|
||||
} from "../utils/tenantFullExport";
|
||||
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
|
||||
import { buildSystemStatus } from "../modules/system-status.service";
|
||||
import { matrixService } from "../modules/matrix.service";
|
||||
import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
|
||||
@@ -385,27 +385,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
let matrixProvisioned = false;
|
||||
let matrixProvisioningError: string | null = null;
|
||||
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
|
||||
try {
|
||||
const matrix = matrixService(server);
|
||||
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
type: "general",
|
||||
});
|
||||
matrixProvisioned = true;
|
||||
} catch (err: any) {
|
||||
matrixProvisioningError = err?.message || String(err);
|
||||
server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matrixProvisioned,
|
||||
matrixProvisioningError,
|
||||
};
|
||||
return { chatReady: true };
|
||||
};
|
||||
|
||||
const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => {
|
||||
@@ -584,29 +564,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
return job;
|
||||
};
|
||||
|
||||
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,
|
||||
email: authUsers.email,
|
||||
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;
|
||||
};
|
||||
const requireAdmin = (req: FastifyRequest, reply: FastifyReply) => requireSystemAdmin(server, req, reply);
|
||||
|
||||
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
|
||||
const existingRoles = await server.db
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
|
||||
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
||||
import { centralServicesClient } from "../modules/push-server.client"
|
||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
@@ -1166,6 +1167,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const { data: payload } = req.body as { data: any }
|
||||
const prepared = prepareStatementAllocationPayload(payload)
|
||||
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
||||
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
|
||||
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
|
||||
|
||||
const inserted = await server.db.insert(statementallocations).values({
|
||||
@@ -1217,6 +1220,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
return reply.send(createdRecord)
|
||||
|
||||
} catch (err) {
|
||||
if (err.statusCode === 400) return reply.code(400).send({ error: err.message })
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Failed to create statement" })
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
||||
GetObjectCommand
|
||||
} from "@aws-sdk/client-s3"
|
||||
import archiver from "archiver"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { saveFile } from "../utils/files"
|
||||
|
||||
@@ -19,6 +20,17 @@ import {
|
||||
|
||||
|
||||
export default async function fileRoutes(server: FastifyInstance) {
|
||||
const createDownloadUrl = (req: any, fileId: string) => {
|
||||
const downloadToken = jwt.sign({
|
||||
user_id: req.user.user_id,
|
||||
email: req.user.email,
|
||||
tenant_id: req.user.tenant_id,
|
||||
is_admin: Boolean(req.user.is_admin),
|
||||
}, secrets.JWT_SECRET!, { expiresIn: "15m" })
|
||||
|
||||
return `/api/files/content/${fileId}?downloadToken=${encodeURIComponent(downloadToken)}`
|
||||
}
|
||||
|
||||
const getPortalCustomerId = async (req: any) => {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
@@ -282,7 +294,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
return { ...file, url: `/api/files/content/${file.id}` }
|
||||
return { ...file, url: createDownloadUrl(req, file.id) }
|
||||
} else {
|
||||
// -------------------------------------------------
|
||||
// MULTIPLE PRESIGNED URLs
|
||||
@@ -300,7 +312,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
|
||||
const output = selected.map(file => ({
|
||||
...file,
|
||||
url: `/api/files/content/${file.id}`
|
||||
url: createDownloadUrl(req, file.id)
|
||||
}))
|
||||
|
||||
return { files: output }
|
||||
|
||||
@@ -21,6 +21,7 @@ import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||
import {citys, files} from "../../db/schema";
|
||||
import {and, eq, isNull, not} from "drizzle-orm";
|
||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||
import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||
@@ -296,15 +297,34 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
})
|
||||
|
||||
server.post('/functions/serial/start', async (req, reply) => {
|
||||
console.log(req.body)
|
||||
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
|
||||
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
|
||||
const {executionDate, templateIds} = req.body as {executionDate:string, templateIds:number[]}
|
||||
return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
|
||||
})
|
||||
|
||||
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
||||
const {execution_id} = req.params as { execution_id: string }
|
||||
//@ts-ignore
|
||||
await finishManualGeneration(server,execution_id)
|
||||
return finishManualGeneration(server, execution_id, req.user.tenant_id)
|
||||
})
|
||||
|
||||
server.put('/functions/outgoing-documents/:id/cost-centres', async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string }
|
||||
const documentId = Number(id)
|
||||
if (!Number.isFinite(documentId)) return reply.code(400).send({ error: "Ungültige Ausgangsbeleg-ID" })
|
||||
|
||||
const document = await updateOutgoingDocumentCostCentres(
|
||||
server,
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
documentId,
|
||||
req.body as any,
|
||||
)
|
||||
return { document }
|
||||
} catch (error) {
|
||||
const statusCode = (error as any)?.statusCode || 500
|
||||
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { getBuildInfo } from "../utils/buildInfo";
|
||||
|
||||
export default async function routes(server: FastifyInstance) {
|
||||
server.get("/health", async (_request, reply) => {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
try {
|
||||
await server.db.execute("SELECT 1");
|
||||
return { status: "ok", ...getBuildInfo() };
|
||||
} catch {
|
||||
return reply.code(503).send({ status: "unavailable" });
|
||||
}
|
||||
});
|
||||
|
||||
server.get("/version", async (_request, reply) => {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return getBuildInfo();
|
||||
});
|
||||
|
||||
server.get("/ping", async () => {
|
||||
// Testquery gegen DB
|
||||
const result = await server.db.execute("SELECT NOW()");
|
||||
@@ -10,4 +26,4 @@ export default async function routes(server: FastifyInstance) {
|
||||
db: JSON.stringify(result.rows[0]),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { authProfiles, historyitems } from "../../db/schema";
|
||||
import { sanitizeHistoryItem, sanitizeHistoryText, sanitizeHistoryValue } from "../utils/historySanitization";
|
||||
|
||||
const columnMap: Record<string, any> = {
|
||||
customers: historyitems.customer,
|
||||
@@ -28,6 +29,7 @@ const columnMap: Record<string, any> = {
|
||||
customerinventoryitems: historyitems.customerinventoryitem,
|
||||
memberrelations: historyitems.memberrelation,
|
||||
outgoingsepamandates: historyitems.outgoingsepamandate,
|
||||
projecttypes: historyitems.projecttype,
|
||||
};
|
||||
|
||||
const insertFieldMap: Record<string, string> = {
|
||||
@@ -55,6 +57,7 @@ const insertFieldMap: Record<string, string> = {
|
||||
customerinventoryitems: "customerinventoryitem",
|
||||
memberrelations: "memberrelation",
|
||||
outgoingsepamandates: "outgoingsepamandate",
|
||||
projecttypes: "projecttype",
|
||||
}
|
||||
|
||||
const parseId = (value: string) => {
|
||||
@@ -93,7 +96,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
profiles.map((profile) => [profile.user_id, profile])
|
||||
);
|
||||
|
||||
return data.map((historyitem) => ({
|
||||
return data.map((historyitem) => sanitizeHistoryItem({
|
||||
...historyitem,
|
||||
created_at: historyitem.createdAt,
|
||||
created_by: historyitem.createdBy,
|
||||
@@ -127,7 +130,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(historyitems)
|
||||
.where(eq(column, parseId(id)))
|
||||
.where(and(
|
||||
eq(historyitems.tenant, req.user?.tenant_id),
|
||||
eq(column, parseId(id))
|
||||
))
|
||||
.orderBy(asc(historyitems.createdAt));
|
||||
|
||||
const userIds = Array.from(
|
||||
@@ -148,7 +154,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
profiles.map((profile) => [profile.user_id, profile])
|
||||
)
|
||||
|
||||
const dataCombined = data.map((historyitem) => ({
|
||||
const dataCombined = data.map((historyitem) => sanitizeHistoryItem({
|
||||
...historyitem,
|
||||
created_at: historyitem.createdAt,
|
||||
created_by: historyitem.createdBy,
|
||||
@@ -218,11 +224,11 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
const inserted = await server.db
|
||||
.insert(historyitems)
|
||||
.values({
|
||||
text,
|
||||
text: sanitizeHistoryText(text),
|
||||
[fkField]: parseId(id),
|
||||
oldVal: old_val || null,
|
||||
newVal: new_val || null,
|
||||
config: config || null,
|
||||
oldVal: sanitizeHistoryValue(old_val) || null,
|
||||
newVal: sanitizeHistoryValue(new_val) || null,
|
||||
config: sanitizeHistoryValue(config) || null,
|
||||
tenant: (req.user as any)?.tenant_id,
|
||||
createdBy: userId
|
||||
})
|
||||
@@ -233,10 +239,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
return reply.code(500).send({ error: "Failed to create history entry" });
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
return reply.code(201).send(sanitizeHistoryItem({
|
||||
...data,
|
||||
created_at: data.createdAt,
|
||||
created_by: data.createdBy
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FastifyInstance, FastifyRequest } from "fastify"
|
||||
import multipart from "@fastify/multipart"
|
||||
import { createHash } from "node:crypto"
|
||||
import { and, asc, eq, sql } from "drizzle-orm"
|
||||
import { basename } from "node:path"
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm"
|
||||
import { instanceAgentScanJobs, instanceAgents } from "../../db/schema"
|
||||
import { saveFile } from "../utils/files"
|
||||
|
||||
@@ -178,11 +179,12 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
|
||||
})
|
||||
.where(and(
|
||||
eq(instanceAgentScanJobs.id, req.params.id),
|
||||
eq(instanceAgentScanJobs.agentId, agent.id)
|
||||
eq(instanceAgentScanJobs.agentId, agent.id),
|
||||
inArray(instanceAgentScanJobs.status, ["pending", "running"])
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (!job) return reply.code(404).send({ error: "Scan job not found" })
|
||||
if (!job) return reply.code(409).send({ error: "Scan job is no longer active or does not exist" })
|
||||
|
||||
return { job }
|
||||
})
|
||||
@@ -209,7 +211,8 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance
|
||||
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
|
||||
|
||||
const fileBuffer = await data.toBuffer()
|
||||
const filename = job.requestedFilename || data.filename || `${job.id}.pdf`
|
||||
// The agent normalizes the extension to the actual output format.
|
||||
const filename = basename(data.filename || job.requestedFilename || `${job.id}.pdf`)
|
||||
|
||||
const createdFile = await saveFile(
|
||||
server,
|
||||
|
||||
@@ -90,10 +90,22 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
|
||||
createdAt: instanceAgents.createdAt,
|
||||
})
|
||||
|
||||
return {
|
||||
agent,
|
||||
token,
|
||||
}
|
||||
reply.header("Cache-Control", "no-store")
|
||||
return { agent, token }
|
||||
})
|
||||
|
||||
server.post<{ Params: { id: string } }>("/instance-agents/:id/token", async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return
|
||||
const id = z.string().uuid().parse(req.params.id)
|
||||
const token = createAgentToken()
|
||||
const [agent] = await server.db
|
||||
.update(instanceAgents)
|
||||
.set({ tokenPrefix: token.slice(0, 24), tokenHash: hashToken(token), lastSeenAt: null, updatedAt: new Date() })
|
||||
.where(eq(instanceAgents.id, id))
|
||||
.returning({ id: instanceAgents.id })
|
||||
if (!agent) return reply.code(404).send({ error: "Agent not found" })
|
||||
reply.header("Cache-Control", "no-store")
|
||||
return { token }
|
||||
})
|
||||
|
||||
server.patch<{ Params: { id: string } }>("/instance-agents/:id", async (req, reply) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||
import { authProfiles, authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||
import { pushServerClient } from "../modules/push-server.client"
|
||||
|
||||
@@ -174,6 +174,77 @@ export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
})
|
||||
})
|
||||
|
||||
server.post("/notifications/test-push/profile/:profileId", async (req, reply) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const { profileId } = req.params as { profileId: string }
|
||||
const [profile] = await server.db
|
||||
.select({
|
||||
userId: authProfiles.user_id,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.id, profileId),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
return reply.code(404).send({ error: "Mitarbeiter nicht gefunden" })
|
||||
}
|
||||
|
||||
if (!profile.userId) {
|
||||
return reply.code(409).send({
|
||||
error: "Der Mitarbeiter ist noch nicht mit einem Benutzerkonto verknüpft",
|
||||
})
|
||||
}
|
||||
|
||||
const devices = await server.db
|
||||
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
|
||||
.from(notificationMobilePushDevices)
|
||||
.where(and(
|
||||
eq(notificationMobilePushDevices.tenantId, tenantId),
|
||||
eq(notificationMobilePushDevices.userId, profile.userId),
|
||||
isNull(notificationMobilePushDevices.disabledAt)
|
||||
))
|
||||
|
||||
if (!devices.length) {
|
||||
return reply.code(409).send({
|
||||
error: "Für diesen Mitarbeiter ist kein aktives mobiles Push-Gerät registriert",
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pushServerClient.sendPush({
|
||||
idempotencyKey: `profile-mobile-test:${tenantId}:${profile.userId}:${Date.now()}`,
|
||||
devices: devices.map((device) => device.centralDeviceId),
|
||||
priority: "high",
|
||||
ttlSeconds: 600,
|
||||
notification: {
|
||||
title: "FEDEO Push ist aktiv",
|
||||
body: "Diese Testbenachrichtigung wurde einmalig über das Mitarbeiterprofil ausgelöst.",
|
||||
},
|
||||
data: {
|
||||
type: "system.test_mobile_push",
|
||||
link: "/",
|
||||
},
|
||||
})
|
||||
|
||||
if (result.accepted === 0) {
|
||||
return reply.code(502).send({
|
||||
error: "Der zentrale Push-Server hat kein Gerät zur Zustellung angenommen",
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error: any) {
|
||||
server.log.error({ err: error, profileId, userId: profile.userId }, "Mitarbeiter-Test-Push fehlgeschlagen")
|
||||
return reply.code(502).send({
|
||||
error: error?.message || "Der zentrale Push-Server konnte die Nachricht nicht annehmen",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/notifications/trigger", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as any
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
eq,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
|
||||
import {
|
||||
authProfiles,
|
||||
costcentres,
|
||||
createddocuments,
|
||||
customers,
|
||||
entitybankaccounts,
|
||||
@@ -324,58 +324,6 @@ function validateMemberPayload(payload: Record<string, any>) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function validateCostCentreParent(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
costCentreId: string | null,
|
||||
parentCostcentreId: string | null
|
||||
) {
|
||||
if (!parentCostcentreId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hierarchyRows = await server.db
|
||||
.select({
|
||||
id: costcentres.id,
|
||||
parentCostcentre: costcentres.parentCostcentre,
|
||||
})
|
||||
.from(costcentres)
|
||||
.where(eq(costcentres.tenant, tenantId))
|
||||
|
||||
const hierarchyMap = new Map(
|
||||
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
|
||||
)
|
||||
|
||||
if (!hierarchyMap.has(parentCostcentreId)) {
|
||||
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
|
||||
}
|
||||
|
||||
if (costCentreId && parentCostcentreId === costCentreId) {
|
||||
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
|
||||
}
|
||||
|
||||
if (!costCentreId) {
|
||||
return null
|
||||
}
|
||||
|
||||
let currentParentId: string | null = parentCostcentreId
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (currentParentId === costCentreId) {
|
||||
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
|
||||
}
|
||||
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
currentParentId = hierarchyMap.get(currentParentId) || null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function maskIban(iban: string) {
|
||||
if (!iban) return ""
|
||||
@@ -1035,7 +983,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
|
||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
||||
const numberRangeColumn = table[config.numberRangeHolder]
|
||||
const tenantColumn = getTenantColumn(resource, table)
|
||||
const result = await useNextNumberRangeNumber(
|
||||
server,
|
||||
req.user.tenant_id,
|
||||
numberRangeResource,
|
||||
async (candidate, tx) => {
|
||||
const existing = await tx
|
||||
.select({ id: table.id })
|
||||
.from(table)
|
||||
.where(and(
|
||||
eq(tenantColumn, req.user!.tenant_id),
|
||||
eq(numberRangeColumn, candidate)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return existing.length === 0
|
||||
}
|
||||
)
|
||||
createData[config.numberRangeHolder] = result.usedNumber
|
||||
}
|
||||
|
||||
|
||||
38
backend/src/routes/selfhostUpdates.ts
Normal file
38
backend/src/routes/selfhostUpdates.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { applyBody, checkBody, createUpdaterClient, requestId, UpdaterError } from "../modules/selfhost-updater";
|
||||
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
|
||||
import { getBuildInfo } from "../utils/buildInfo";
|
||||
|
||||
export default async function selfhostUpdateRoutes(server: FastifyInstance, options: { client?: ReturnType<typeof createUpdaterClient> }) {
|
||||
const client = options.client || createUpdaterClient();
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
await requireSystemAdmin(server, req, reply);
|
||||
});
|
||||
const forward = async (reply: any, method: "GET" | "POST", path: string, body?: unknown) => {
|
||||
try {
|
||||
const result = await client(method, path, body);
|
||||
return reply.code(result.status).send(path === "/v1/status"
|
||||
? { ...result.body as object, backend: getBuildInfo() } : result.body);
|
||||
} catch (error) {
|
||||
const safe = error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE");
|
||||
return reply.code(safe.status).send({ error: { code: safe.code, message: safe.message } });
|
||||
}
|
||||
};
|
||||
server.get("/admin/updates/status", async (_req, reply) => forward(reply, "GET", "/v1/status"));
|
||||
server.get<{ Params: { id: string } }>("/admin/updates/requests/:id", async (req, reply) => {
|
||||
if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
|
||||
return forward(reply, "GET", `/v1/requests/${req.params.id}`);
|
||||
});
|
||||
for (const command of ["check", "apply"] as const) {
|
||||
server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => {
|
||||
// The auth plugin verifies this JWT. Cookies alone cannot trigger host operations (CSRF).
|
||||
if (!/^Bearer \S{11,}$/.test(req.headers.authorization || "")) {
|
||||
return reply.code(403).send({ error: { code: "BEARER_REQUIRED", message: "Bitte erneut anmelden." } });
|
||||
}
|
||||
const parsed = (command === "apply" ? applyBody : checkBody).safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
|
||||
return forward(reply, "POST", `/v1/${command}`, parsed.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
7
backend/src/utils/buildInfo.ts
Normal file
7
backend/src/utils/buildInfo.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function getBuildInfo() {
|
||||
return {
|
||||
product: "FEDEO",
|
||||
version: process.env.FEDEO_VERSION || "0.0.0-dev",
|
||||
revision: process.env.FEDEO_REVISION || "unknown",
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
vendors,
|
||||
bankaccounts,
|
||||
bankstatements,
|
||||
ownaccounts
|
||||
ownaccounts,
|
||||
costcentres
|
||||
} from "../../../db/schema";
|
||||
|
||||
dayjs.extend(isBetween);
|
||||
@@ -80,6 +81,29 @@ const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
|
||||
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : "";
|
||||
};
|
||||
|
||||
export const getBankAccountBookingLine = (alloc: any) => {
|
||||
const bs = alloc.bankstatement;
|
||||
const account = alloc.account || alloc.ownaccount;
|
||||
const columns = Array(124).fill("");
|
||||
const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`;
|
||||
columns[0] = displayCurrency(Number(alloc.amount), true);
|
||||
columns[1] = quote(Number(alloc.amount) < 0 ? "H" : "S");
|
||||
columns[6] = bs.account?.datevNumber || "";
|
||||
columns[7] = account.number;
|
||||
columns[8] = quote(alloc.datevTaxKey || "");
|
||||
columns[9] = formatDatevDate(bs.date, "DDMM");
|
||||
columns[13] = quote(`${Number(alloc.amount) > 0 ? "ZE" : "ZA"} ${account.number} - ${account.label || account.name || ""} ${alloc.description || ""} ${bs.text || ""}`.substring(0, 59));
|
||||
columns[20] = quote("Geschäftspartner");
|
||||
columns[21] = quote(bs.debName || bs.credName || "");
|
||||
columns[28] = quote("Belegdatum");
|
||||
columns[29] = quote(formatDatevDate(bs.date, "DD.MM.YYYY"));
|
||||
columns[36] = quote(alloc.costcentre?.number || "");
|
||||
columns[47] = "Bank-Id";
|
||||
columns[48] = bs.id;
|
||||
columns[112] = "0";
|
||||
return columns.join(";");
|
||||
};
|
||||
|
||||
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
||||
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
||||
|
||||
@@ -136,7 +160,7 @@ export async function buildExportZip(
|
||||
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD");
|
||||
|
||||
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
|
||||
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
|
||||
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1. DATEN LADEN (CORE API SELECT & JOIN)
|
||||
@@ -209,6 +233,7 @@ export async function buildExportZip(
|
||||
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
||||
direct_cust: customers, // Direkte Zuordnung an Debitor
|
||||
own: ownaccounts,
|
||||
costcentre: costcentres,
|
||||
contra_acc: ContraAccount,
|
||||
contra_vend: ContraVendor,
|
||||
contra_cust: ContraCustomer,
|
||||
@@ -233,6 +258,7 @@ export async function buildExportZip(
|
||||
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||
.leftJoin(costcentres, and(eq(statementallocations.costcentre, costcentres.id), eq(costcentres.tenant, tenantId)))
|
||||
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
|
||||
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
|
||||
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
|
||||
@@ -257,10 +283,10 @@ export async function buildExportZip(
|
||||
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
|
||||
const statementallocationsList = allocRaw.map(r => ({
|
||||
...r.allocation,
|
||||
bankstatement: {
|
||||
bankstatement: r.bs ? {
|
||||
...r.bs,
|
||||
account: r.ba // Nesting für bs.account.datevNumber
|
||||
},
|
||||
} : null,
|
||||
createddocument: r.cd ? {
|
||||
...r.cd,
|
||||
customer: r.cd_cust
|
||||
@@ -273,6 +299,7 @@ export async function buildExportZip(
|
||||
vendor: r.direct_vend,
|
||||
customer: r.direct_cust,
|
||||
ownaccount: r.own,
|
||||
costcentre: r.costcentre,
|
||||
contraAccount: r.contra_acc,
|
||||
contraVendor: r.contra_vend,
|
||||
contraCustomer: r.contra_cust,
|
||||
@@ -453,9 +480,7 @@ export async function buildExportZip(
|
||||
const vend = ii.vendor;
|
||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||
} else if(alloc.account) {
|
||||
const acc = alloc.account;
|
||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||
} else if(alloc.vendor) {
|
||||
const vend = alloc.vendor;
|
||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||
@@ -465,9 +490,7 @@ export async function buildExportZip(
|
||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||
} else if(alloc.ownaccount) {
|
||||
const own = alloc.ownaccount;
|
||||
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
|
||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import { eq, sql } from "drizzle-orm"
|
||||
export const useNextNumberRangeNumber = async (
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
numberRange: string
|
||||
numberRange: string,
|
||||
isAvailable: (candidate: string, tx: any) => Promise<boolean> = async () => true
|
||||
) => {
|
||||
const numberRangeFallbacks: Record<string, string> = {
|
||||
costEstimates: "quotes",
|
||||
@@ -44,18 +45,39 @@ export const useNextNumberRangeNumber = async (
|
||||
}
|
||||
|
||||
const current = numberRanges[resolvedNumberRange]
|
||||
let nextNumber = Number(current.nextNumber)
|
||||
|
||||
const usedNumber =
|
||||
(current.prefix || "") +
|
||||
current.nextNumber +
|
||||
(current.suffix || "")
|
||||
if (!Number.isFinite(nextNumber)) {
|
||||
throw new Error(`Number range '${resolvedNumberRange}' has an invalid nextNumber`)
|
||||
}
|
||||
|
||||
let usedNumber = ""
|
||||
const maxAttempts = 10000
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const candidate =
|
||||
(current.prefix || "") +
|
||||
nextNumber +
|
||||
(current.suffix || "")
|
||||
|
||||
if (await isAvailable(candidate, tx)) {
|
||||
usedNumber = candidate
|
||||
break
|
||||
}
|
||||
|
||||
nextNumber++
|
||||
}
|
||||
|
||||
if (!usedNumber) {
|
||||
throw new Error(`Could not find an available number in range '${resolvedNumberRange}'`)
|
||||
}
|
||||
|
||||
const updatedRanges = {
|
||||
// @ts-ignore
|
||||
...numberRanges,
|
||||
[resolvedNumberRange]: {
|
||||
...current,
|
||||
nextNumber: current.nextNumber + 1,
|
||||
nextNumber: nextNumber + 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { historyitems } from "../../db/schema";
|
||||
import { sanitizeHistoryText, sanitizeHistoryValue } from "./historySanitization";
|
||||
|
||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
customers: "Kunden",
|
||||
@@ -35,6 +36,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
memberrelations: "Mitgliedsverhältnisse",
|
||||
teams: "Teams",
|
||||
outgoingsepamandates: "Ausgehende SEPA-Mandate",
|
||||
projecttypes: "Projekttypen",
|
||||
}
|
||||
|
||||
export function getHistoryEntityLabel(entity: string) {
|
||||
@@ -96,6 +98,7 @@ export async function insertHistoryItem(
|
||||
files: "file",
|
||||
memberrelations: "memberrelation",
|
||||
outgoingsepamandates: "outgoingsepamandate",
|
||||
projecttypes: "projecttype",
|
||||
}
|
||||
|
||||
const fkColumn = columnMap[params.entity]
|
||||
@@ -112,11 +115,11 @@ export async function insertHistoryItem(
|
||||
const entry = {
|
||||
tenant: params.tenant_id,
|
||||
createdBy: params.created_by,
|
||||
text: params.text || textMap[params.action],
|
||||
text: sanitizeHistoryText(params.text || textMap[params.action]),
|
||||
action: params.action,
|
||||
[fkColumn]: params.entityId,
|
||||
oldVal: stringifyHistoryValue(params.oldVal),
|
||||
newVal: stringifyHistoryValue(params.newVal)
|
||||
oldVal: stringifyHistoryValue(sanitizeHistoryValue(params.oldVal)),
|
||||
newVal: stringifyHistoryValue(sanitizeHistoryValue(params.newVal))
|
||||
}
|
||||
|
||||
await server.db.insert(historyitems).values(entry as any)
|
||||
|
||||
45
backend/src/utils/historySanitization.ts
Normal file
45
backend/src/utils/historySanitization.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const IBAN_IN_TEXT_PATTERN = /\b([A-Z]{2}\d{2}(?:[\s-]?[A-Z0-9]){11,30})(?=["'\]},.;:!?)]|$)/gi
|
||||
|
||||
export function maskIban(iban: string): string {
|
||||
const normalized = iban.replace(/[\s-]+/g, "").toUpperCase()
|
||||
if (normalized.length <= 8) return normalized
|
||||
return `${normalized.slice(0, 4)} **** **** ${normalized.slice(-4)}`
|
||||
}
|
||||
|
||||
export function sanitizeHistoryText(text: string): string {
|
||||
return text.replace(IBAN_IN_TEXT_PATTERN, (candidate) => maskIban(candidate))
|
||||
}
|
||||
|
||||
function sanitizeIbanField(value: any): any {
|
||||
if (typeof value === "string") return maskIban(value)
|
||||
if (Array.isArray(value)) return value.map(sanitizeIbanField)
|
||||
return sanitizeHistoryValue(value)
|
||||
}
|
||||
|
||||
export function sanitizeHistoryValue(value: any): any {
|
||||
if (typeof value === "string") return sanitizeHistoryText(value)
|
||||
if (Array.isArray(value)) return value.map(sanitizeHistoryValue)
|
||||
if (!value || typeof value !== "object" || value instanceof Date) return value
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) return value
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, nestedValue]) => [
|
||||
key,
|
||||
key.toLowerCase().includes("iban")
|
||||
? sanitizeIbanField(nestedValue)
|
||||
: sanitizeHistoryValue(nestedValue),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeHistoryItem<T extends Record<string, any>>(item: T): T {
|
||||
return {
|
||||
...item,
|
||||
text: typeof item.text === "string" ? sanitizeHistoryText(item.text) : item.text,
|
||||
oldVal: sanitizeHistoryValue(item.oldVal),
|
||||
newVal: sanitizeHistoryValue(item.newVal),
|
||||
config: sanitizeHistoryValue(item.config),
|
||||
}
|
||||
}
|
||||
18
backend/src/utils/requireSystemAdmin.ts
Normal file
18
backend/src/utils/requireSystemAdmin.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authUsers } from "../../db/schema/auth_users";
|
||||
|
||||
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user?.user_id) {
|
||||
reply.code(401).send({ error: "Unauthorized" });
|
||||
return null;
|
||||
}
|
||||
// Read the current global role; tenant roles and stale JWT claims do not grant access.
|
||||
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
|
||||
.from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1);
|
||||
if (!user?.is_admin) {
|
||||
reply.code(403).send({ error: "Admin access required" });
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
@@ -147,6 +147,7 @@ export const resourceConfig = {
|
||||
},
|
||||
products: {
|
||||
table: products,
|
||||
mtoLoad: ["unit"],
|
||||
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
|
||||
},
|
||||
productcategories: {
|
||||
|
||||
@@ -38,15 +38,6 @@ export let secrets = {
|
||||
DOKUBOX_IMAP_PASSWORD: string
|
||||
OPENAI_API_KEY: string
|
||||
STIRLING_API_KEY: string
|
||||
MATRIX_HOMESERVER_URL?: string
|
||||
MATRIX_SERVER_NAME?: string
|
||||
MATRIX_RTC_HOST?: string
|
||||
MATRIX_RTC_JWT_URL?: string
|
||||
MATRIX_LIVEKIT_URL?: string
|
||||
MATRIX_REGISTRATION_SHARED_SECRET?: string
|
||||
MATRIX_SERVICE_USER_LOCALPART?: string
|
||||
LIVEKIT_KEY?: string
|
||||
LIVEKIT_SECRET?: string
|
||||
WEB_PUSH_PUBLIC_KEY?: string
|
||||
WEB_PUSH_PRIVATE_KEY?: string
|
||||
WEB_PUSH_SUBJECT?: string
|
||||
@@ -88,15 +79,6 @@ const secretKeys = [
|
||||
"DOKUBOX_IMAP_PASSWORD",
|
||||
"OPENAI_API_KEY",
|
||||
"STIRLING_API_KEY",
|
||||
"MATRIX_HOMESERVER_URL",
|
||||
"MATRIX_SERVER_NAME",
|
||||
"MATRIX_RTC_HOST",
|
||||
"MATRIX_RTC_JWT_URL",
|
||||
"MATRIX_LIVEKIT_URL",
|
||||
"MATRIX_REGISTRATION_SHARED_SECRET",
|
||||
"MATRIX_SERVICE_USER_LOCALPART",
|
||||
"LIVEKIT_KEY",
|
||||
"LIVEKIT_SECRET",
|
||||
"WEB_PUSH_PUBLIC_KEY",
|
||||
"WEB_PUSH_PRIVATE_KEY",
|
||||
"WEB_PUSH_SUBJECT",
|
||||
|
||||
@@ -86,47 +86,6 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
|
||||
const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"])
|
||||
|
||||
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
|
||||
const matrixServerName = () =>
|
||||
process.env.MATRIX_SERVER_NAME ||
|
||||
secrets.MATRIX_SERVER_NAME ||
|
||||
process.env.DOMAIN ||
|
||||
"localhost"
|
||||
|
||||
const normalizeMatrixLocalpartSeed = (value: string) => {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ä/g, "a")
|
||||
.replace(/ö/g, "o")
|
||||
.replace(/ü/g, "u")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9._=-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^[._=-]+|[._=-]+$/g, "")
|
||||
|
||||
return normalized || "user"
|
||||
}
|
||||
|
||||
const normalizeMatrixAliasSeed = (value: string) =>
|
||||
normalizeMatrixLocalpartSeed(value)
|
||||
.replace(/[.=]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
|
||||
const tenantRoomAliasLocalpart = (
|
||||
tenant: { id: number, short?: string | null, name?: string | null },
|
||||
roomKey: string
|
||||
) => {
|
||||
const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`)
|
||||
const roomSeed = normalizeMatrixAliasSeed(roomKey)
|
||||
return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}`
|
||||
}
|
||||
|
||||
const tenantRoomAlias = (
|
||||
tenant: { id: number, short?: string | null, name?: string | null },
|
||||
roomKey: string
|
||||
) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${matrixServerName()}`
|
||||
|
||||
const tableColumns = async (client: any) => {
|
||||
const result = await client.query(`
|
||||
select table_name, column_name, data_type, is_generated
|
||||
@@ -343,6 +302,12 @@ export const buildTenantFullExport = async (
|
||||
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
|
||||
}
|
||||
|
||||
const communicationRoomIds = collectIds(tables.communication_rooms || [], "id")
|
||||
if (communicationRoomIds.length) {
|
||||
addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds]))
|
||||
addRows(tables, "communication_room_reads", await loadRows(client, "communication_room_reads", "room_id = any($1::uuid[])", [communicationRoomIds]))
|
||||
}
|
||||
|
||||
if (tables.entitybankaccounts?.length) {
|
||||
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
|
||||
}
|
||||
@@ -636,73 +601,6 @@ const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) =>
|
||||
}
|
||||
}
|
||||
|
||||
const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => {
|
||||
const rows = exportData.tables.communication_rooms || []
|
||||
if (!rows.length) return
|
||||
|
||||
const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [
|
||||
Number(tenant.id),
|
||||
{
|
||||
id: Number(tenant.id),
|
||||
name: tenant.name,
|
||||
short: tenant.short,
|
||||
},
|
||||
]))
|
||||
|
||||
for (const row of rows) {
|
||||
const tenantId = Number(row.tenant_id)
|
||||
const tenant = tenantById.get(tenantId)
|
||||
|
||||
row.matrix_room_id = null
|
||||
row.parent_space_room_id = null
|
||||
|
||||
if (tenant && row.key) {
|
||||
row.matrix_alias = tenantRoomAlias(tenant, String(row.key))
|
||||
} else {
|
||||
row.matrix_alias = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => {
|
||||
const rows = exportData.tables.communication_rooms || []
|
||||
if (!rows.length) return 0
|
||||
|
||||
const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [
|
||||
Number(tenant.id),
|
||||
{
|
||||
id: Number(tenant.id),
|
||||
name: tenant.name,
|
||||
short: tenant.short,
|
||||
},
|
||||
]))
|
||||
let cleaned = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const tenantId = Number(row.tenant_id)
|
||||
const key = String(row.key || "")
|
||||
const tenant = tenantById.get(tenantId)
|
||||
if (!tenantId || !key || !tenant) continue
|
||||
|
||||
const alias = tenantRoomAlias(tenant, key)
|
||||
const result = await client.query(
|
||||
`
|
||||
update communication_rooms
|
||||
set matrix_room_id = null,
|
||||
parent_space_room_id = null,
|
||||
matrix_alias = $3,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and key = $2
|
||||
`,
|
||||
[tenantId, key, alias]
|
||||
)
|
||||
|
||||
cleaned += result.rowCount || 0
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
|
||||
if (!isJsonColumn || value === null || typeof value === "undefined") return value
|
||||
if (typeof value === "string") return value
|
||||
@@ -853,7 +751,6 @@ export const importTenantFullExport = async (
|
||||
|
||||
const exportData = rawExportData
|
||||
encryptEntityBankAccountRowsForImport(exportData)
|
||||
prepareCommunicationRoomsForImport(exportData)
|
||||
const client = await pool.connect()
|
||||
const importOrder = [
|
||||
"tenants",
|
||||
@@ -955,13 +852,6 @@ export const importTenantFullExport = async (
|
||||
await reportProgress(`${table} importiert`)
|
||||
}
|
||||
|
||||
const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData)
|
||||
if (cleanedCommunicationRooms) {
|
||||
importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms })
|
||||
}
|
||||
progressDone += 1
|
||||
await reportProgress("Kommunikationsräume bereinigt")
|
||||
|
||||
await refreshSequences(client, columnsByTable)
|
||||
progressDone = progressTotal
|
||||
await reportProgress("Import abgeschlossen")
|
||||
|
||||
69
backend/tests/bankAllocationDetails.test.ts
Normal file
69
backend/tests/bankAllocationDetails.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { PgDialect } from "drizzle-orm/pg-core"
|
||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../src/modules/bank-allocation-details"
|
||||
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
|
||||
import bankingRoutes from "../src/routes/banking"
|
||||
import { statementallocations } from "../db/schema"
|
||||
|
||||
const costcentre = "57d31d62-11a2-47c5-b074-69f9c5ab8bba"
|
||||
|
||||
test("keeps cost centre and VAT key on the gross bank allocation through input preparation", () => {
|
||||
const prepared = prepareStatementAllocationInput({ bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: " 9 " })
|
||||
assert.equal(prepared.amount, -119)
|
||||
assert.equal(prepared.costcentre, costcentre)
|
||||
assert.equal(prepared.datevTaxKey, "9")
|
||||
assert.deepEqual(normalizeBankAllocationDetails({ amount: -119, account: 13, bankstatement: 42 }), { costcentre: null, datevTaxKey: null })
|
||||
})
|
||||
|
||||
test("rejects invalid cost centres, ambiguous document assignments and malformed tax keys", () => {
|
||||
assert.throws(() => normalizeBankAllocationDetails({ costcentre: "invalid" }), /UUID/)
|
||||
assert.throws(() => normalizeBankAllocationDetails({ costcentre, bankstatement: 1, account: 13, incominginvoice: 2 }), /direkte Bankbuchungen/)
|
||||
assert.throws(() => normalizeBankAllocationDetails({ datevTaxKey: '9";evil' }), /Steuerschlüssel/)
|
||||
})
|
||||
|
||||
test("cost centre lookup requires the active tenant and excludes archived centres", async () => {
|
||||
let condition: any
|
||||
const query = { from: () => query, where: (value: any) => { condition = value; return query }, limit: async () => [] }
|
||||
await assert.rejects(validateBankAllocationCostCentre({ select: () => query }, 7, costcentre), /nicht gefunden oder ist archiviert/)
|
||||
const sql = new PgDialect().sqlToQuery(condition)
|
||||
assert.match(sql.sql, /"costcentres"\."tenant"/)
|
||||
assert.match(sql.sql, /"costcentres"\."archived"/)
|
||||
assert.deepEqual(sql.params, [costcentre, 7, false])
|
||||
await validateBankAllocationCostCentre({ select: () => ({ ...query, from: () => ({ where: () => ({ limit: async () => [{ id: costcentre }] }) }) }) }, 7, costcentre)
|
||||
await validateBankAllocationCostCentre({}, 7, null)
|
||||
})
|
||||
|
||||
test("banking API persists the gross allocation with cost centre and VAT, and rejects unknown centres before writing", async () => {
|
||||
const handlers = new Map<string, any>()
|
||||
const saved: any[] = []
|
||||
let centreExists = true
|
||||
const lookup: any = { from: () => lookup, where: () => lookup, limit: async () => centreExists ? [{ id: costcentre }] : [] }
|
||||
const server: any = {
|
||||
get() {}, patch() {}, delete() {},
|
||||
post: (url: string, handler: any) => handlers.set(url, handler),
|
||||
db: {
|
||||
select: () => lookup,
|
||||
insert: (table: any) => ({ values: (value: any) => {
|
||||
if (table === statementallocations) saved.push(value)
|
||||
return { returning: async () => [{ id: "allocation", ...value }] }
|
||||
} }),
|
||||
},
|
||||
}
|
||||
await bankingRoutes(server)
|
||||
const handler = handlers.get("/banking/statements")
|
||||
const request = { user: { tenant_id: 7, user_id: "user" }, body: { data: { bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: "9" } } }
|
||||
let status = 200
|
||||
let response: any
|
||||
const reply = { code: (code: number) => { status = code; return reply }, send: (body: any) => { response = body; return reply } }
|
||||
await handler(request, reply)
|
||||
assert.equal(status, 200)
|
||||
assert.equal(response.amount, -119)
|
||||
assert.equal(response.costcentre, costcentre)
|
||||
assert.equal(response.datevTaxKey, "9")
|
||||
assert.equal(saved[0].tenant, 7)
|
||||
centreExists = false
|
||||
await handler(request, reply)
|
||||
assert.equal(status, 400)
|
||||
assert.equal(saved.length, 1)
|
||||
})
|
||||
47
backend/tests/buildInfo.test.ts
Normal file
47
backend/tests/buildInfo.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import healthRoutes from '../src/routes/health';
|
||||
|
||||
test('health checks the database and reports a failure without leaking connection details', async () => {
|
||||
const app = Fastify();
|
||||
let available = true;
|
||||
app.decorate('db', { execute: async () => {
|
||||
if (!available) throw new Error('postgres://secret@database');
|
||||
return { rows: [{ result: 1 }] };
|
||||
} } as any);
|
||||
try {
|
||||
await app.register(healthRoutes);
|
||||
const healthy = await app.inject('/health');
|
||||
assert.equal(healthy.statusCode, 200);
|
||||
assert.equal(healthy.json().status, 'ok');
|
||||
assert.equal(healthy.headers['cache-control'], 'no-store');
|
||||
available = false;
|
||||
const unhealthy = await app.inject('/health');
|
||||
assert.equal(unhealthy.statusCode, 503);
|
||||
assert.deepEqual(unhealthy.json(), { status: 'unavailable' });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('version endpoint reports image metadata without a database or authentication', async () => {
|
||||
const previousVersion = process.env.FEDEO_VERSION;
|
||||
const previousRevision = process.env.FEDEO_REVISION;
|
||||
process.env.FEDEO_VERSION = '1.2.3';
|
||||
process.env.FEDEO_REVISION = 'a'.repeat(40);
|
||||
const app = Fastify();
|
||||
try {
|
||||
await app.register(healthRoutes);
|
||||
const response = await app.inject({ method: 'GET', url: '/version' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.deepEqual(response.json(), { product: 'FEDEO', version: '1.2.3', revision: 'a'.repeat(40) });
|
||||
} finally {
|
||||
await app.close();
|
||||
if (previousVersion === undefined) delete process.env.FEDEO_VERSION;
|
||||
else process.env.FEDEO_VERSION = previousVersion;
|
||||
if (previousRevision === undefined) delete process.env.FEDEO_REVISION;
|
||||
else process.env.FEDEO_REVISION = previousRevision;
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { getCreatedDocumentRevenueLines } from "../src/utils/export/datev";
|
||||
import { buildExportZip, getBankAccountBookingLine, getCreatedDocumentRevenueLines } from "../src/utils/export/datev";
|
||||
import { Uint8ArrayReader, TextWriter, ZipReader } from "@zip.js/zip.js";
|
||||
import { statementallocations } from "../db/schema";
|
||||
|
||||
test("exports gross amounts for DATEV automatic revenue accounts", () => {
|
||||
const revenueLines = getCreatedDocumentRevenueLines({
|
||||
@@ -27,3 +29,77 @@ test("preserves negative gross amounts for cancellations", () => {
|
||||
|
||||
assert.deepEqual(revenueLines, [{ account: "8400", amount: -119 }]);
|
||||
});
|
||||
|
||||
for (const [amount, taxKey, side] of [[-119, "9", "H"], [-107, "8", "H"], [119, "3", "S"], [107, "2", "S"], [119, "9", "S"], [-119, "3", "H"], [-100, "40", "H"]] as const) {
|
||||
test(`exports gross ${amount} with tax key ${taxKey} and cost centre`, () => {
|
||||
const columns = getBankAccountBookingLine({
|
||||
amount, datevTaxKey: taxKey, account: { number: 4900, label: "Konto" },
|
||||
costcentre: { id: "internal-uuid", number: "KST-1001" },
|
||||
bankstatement: { id: 42, date: "2026-09-23", account: { datevNumber: "1200" } },
|
||||
}).split(";");
|
||||
assert.equal(columns.length, 124);
|
||||
assert.equal(columns[0], `${Math.abs(amount)},00`);
|
||||
assert.equal(columns[1], `"${side}"`);
|
||||
assert.equal(columns[6], "1200");
|
||||
assert.equal(columns[7], "4900");
|
||||
assert.equal(columns[8], `"${taxKey}"`);
|
||||
assert.equal(columns[36], '"KST-1001"');
|
||||
assert.equal(columns[112], "0");
|
||||
});
|
||||
}
|
||||
|
||||
test("exports legacy allocations and additional accounts without inventing a tax key", () => {
|
||||
const columns = getBankAccountBookingLine({
|
||||
amount: -12.34, ownaccount: { number: "4901", name: "Zusatzkonto" },
|
||||
bankstatement: { id: 1, date: "2026-09-23", account: { datevNumber: "1200" } },
|
||||
}).split(";");
|
||||
assert.equal(columns[0], "12,34");
|
||||
assert.equal(columns[7], "4901");
|
||||
assert.equal(columns[8], '""');
|
||||
assert.equal(columns[36], '""');
|
||||
});
|
||||
|
||||
test("ZIP export carries allocation metadata into the matching CSV columns and leaves invoice payments untaxed", async () => {
|
||||
const bank = { id: 42, date: "2026-09-23", text: "Zahlung" };
|
||||
const rows = [
|
||||
{ allocation: { id: "a", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, acc: { number: 4900 }, costcentre: { number: "1001" } },
|
||||
{ allocation: { id: "b", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, own: { number: 4901 }, costcentre: { number: "1002" } },
|
||||
{ allocation: { id: "c", amount: -119, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, ii: { id: 3, date: bank.date, reference: "ER-3" }, ii_vend: { vendorNumber: 70001 } },
|
||||
{ allocation: { id: "d", amount: 119, datevTaxKey: "9", manualBookingDate: bank.date }, bs: null, acc: { number: 4900 }, contra_acc: { number: 1200 } },
|
||||
];
|
||||
const db = {
|
||||
select: () => {
|
||||
let result: any[] = [];
|
||||
const query: any = {
|
||||
from: (table: any) => { result = table === statementallocations ? rows : []; return query; },
|
||||
leftJoin: () => query, where: () => query, orderBy: () => query,
|
||||
then: (resolve: any) => Promise.resolve(result).then(resolve),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
};
|
||||
const archive = await buildExportZip({ db } as any, 1, "2026-09-01", "2026-09-30", "123", "456");
|
||||
const reader = new ZipReader(new Uint8ArrayReader(archive));
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
const csvEntry = entries.find(entry => entry.filename.startsWith("EXTF_Buchungsstapel"));
|
||||
assert.ok(csvEntry && "getData" in csvEntry);
|
||||
const csv = await csvEntry.getData!(new TextWriter());
|
||||
const [, header, ...bookings] = csv.trim().split("\n").map(line => line.split(";"));
|
||||
assert.equal(bookings.length, 4);
|
||||
assert.equal(header.length, 124);
|
||||
for (const [i, costcentre] of ["1001", "1002"].entries()) {
|
||||
assert.equal(bookings[i].length, header.length);
|
||||
assert.equal(bookings[i][header.indexOf("Umsatz")], "59,50");
|
||||
assert.equal(bookings[i][header.indexOf("BU-Schluessel")], '"9"');
|
||||
assert.equal(bookings[i][header.indexOf("KOST1 - Kostenstelle")], `"${costcentre}"`);
|
||||
assert.equal(bookings[i][header.indexOf("Festschreibung")], "0");
|
||||
}
|
||||
assert.equal(bookings[2][8], '""');
|
||||
assert.equal(bookings[3][8], '"9"');
|
||||
assert.equal(bookings[3][6], "4900");
|
||||
assert.equal(bookings[3][7], "1200");
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
55
backend/tests/historySanitization.test.ts
Normal file
55
backend/tests/historySanitization.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import {
|
||||
maskIban,
|
||||
sanitizeHistoryItem,
|
||||
sanitizeHistoryText,
|
||||
sanitizeHistoryValue,
|
||||
} from "../src/utils/historySanitization"
|
||||
|
||||
const IBAN = "DE89370400440532013000"
|
||||
|
||||
test("masks all but the first and last four IBAN characters", () => {
|
||||
assert.equal(maskIban(IBAN), "DE89 **** **** 3000")
|
||||
assert.equal(maskIban("DE89 3704 0044 0532 0130 00"), "DE89 **** **** 3000")
|
||||
})
|
||||
|
||||
test("masks IBAN values in nested history data", () => {
|
||||
const sanitized = sanitizeHistoryValue({
|
||||
name: "Beispielkunde",
|
||||
infoData: {
|
||||
bankingIban: IBAN,
|
||||
bankingIbans: [IBAN, "AT61 1904 3002 3457 3201"],
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(sanitized, {
|
||||
name: "Beispielkunde",
|
||||
infoData: {
|
||||
bankingIban: "DE89 **** **** 3000",
|
||||
bankingIbans: ["DE89 **** **** 3000", "AT61 **** **** 3201"],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("masks IBANs embedded in generated history text", () => {
|
||||
const text = `Kunden: Info Daten geändert von "{\"bankingIbans\":[\"${IBAN}\"]}"`
|
||||
const sanitized = sanitizeHistoryText(text)
|
||||
|
||||
assert.equal(sanitized.includes(IBAN), false)
|
||||
assert.equal(sanitized.includes("DE89 **** **** 3000"), true)
|
||||
})
|
||||
|
||||
test("sanitizes existing history items before they are returned", () => {
|
||||
const sanitized = sanitizeHistoryItem({
|
||||
text: `IBAN: ${IBAN}.`,
|
||||
oldVal: JSON.stringify({ bankingIban: IBAN }),
|
||||
newVal: { iban: IBAN },
|
||||
config: null,
|
||||
})
|
||||
|
||||
assert.equal(sanitized.text, "IBAN: DE89 **** **** 3000.")
|
||||
assert.equal(sanitized.oldVal.includes(IBAN), false)
|
||||
assert.deepEqual(sanitized.newVal, { iban: "DE89 **** **** 3000" })
|
||||
})
|
||||
89
backend/tests/instanceAgentGateway.test.ts
Normal file
89
backend/tests/instanceAgentGateway.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
import Fastify from "fastify"
|
||||
import { getTableColumns, getTableName } from "drizzle-orm"
|
||||
import { PgDialect } from "drizzle-orm/pg-core"
|
||||
|
||||
// Load the real route with only file storage replaced; tests never contact S3 or a live database.
|
||||
const routePath = path.resolve(__dirname, "../src/routes/instanceAgentGateway.ts")
|
||||
const routeRequire = createRequire(routePath)
|
||||
const compiled = ts.transpileModule(readFileSync(routePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
||||
}).outputText
|
||||
|
||||
const fixture = async (status = "running") => {
|
||||
const token = "test-agent-token"
|
||||
const agent = { id: "agent", tokenHash: createHash("sha256").update(token).digest("hex"), active: true }
|
||||
const job: any = { id: "job", agentId: "agent", tenantId: 7, requestedBy: "user", requestedFilename: "scan.png", target: { folder: "folder" }, status }
|
||||
const saved: any[] = []
|
||||
const rows: Record<string, any[]> = { instance_agents: [agent], instance_agent_scan_jobs: [job] }
|
||||
const dialect = new PgDialect()
|
||||
const matching = (table: any, condition: any) => {
|
||||
const query = dialect.sqlToQuery(condition)
|
||||
const columns = getTableColumns(table) as Record<string, any>
|
||||
const clauses = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)]
|
||||
assert.ok(clauses.length, query.sql)
|
||||
return rows[getTableName(table)].filter(row => clauses.every(match => {
|
||||
const key = Object.keys(columns).find(key => columns[key].name === match[1])!
|
||||
if (match[2]) return row[key] === query.params[Number(match[2]) - 1]
|
||||
return [...match[3].matchAll(/\$(\d+)/g)].some(parameter => row[key] === query.params[Number(parameter[1]) - 1])
|
||||
}))
|
||||
}
|
||||
const db = {
|
||||
select: () => ({ from: (table: any) => ({ where: (where: any) => ({ limit: async () => matching(table, where) }) }) }),
|
||||
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({ returning: async () => matching(table, where).map(row => Object.assign(row, data)) }) }) }),
|
||||
}
|
||||
const exports: any = {}
|
||||
vm.runInNewContext(compiled, {
|
||||
exports,
|
||||
require: (name: string) => name === "../utils/files"
|
||||
? { saveFile: async (...args: any[]) => { saved.push(args); return { id: "saved-file" } } }
|
||||
: routeRequire(name),
|
||||
})
|
||||
const app = Fastify()
|
||||
app.decorate("db", db)
|
||||
await app.register(exports.default)
|
||||
return { app, job, saved, headers: { "x-agent-token": token } }
|
||||
}
|
||||
|
||||
test("late failure status cannot overwrite an already completed scan", async (t) => {
|
||||
const f = await fixture("completed")
|
||||
t.after(() => f.app.close())
|
||||
const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status: "failed", message: "upload timeout" } })
|
||||
assert.equal(response.statusCode, 409)
|
||||
assert.equal(f.job.status, "completed")
|
||||
})
|
||||
|
||||
test("running jobs accept progress and scanner failures", async (t) => {
|
||||
const f = await fixture()
|
||||
t.after(() => f.app.close())
|
||||
for (const status of ["running", "failed"]) {
|
||||
const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status, message: "4 Seiten" } })
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.equal(f.job.status, status)
|
||||
}
|
||||
})
|
||||
|
||||
test("upload preserves the actual PDF filename, tenant and target folder", async (t) => {
|
||||
const f = await fixture()
|
||||
t.after(() => f.app.close())
|
||||
const boundary = "fedeo-test-boundary"
|
||||
const response = await f.app.inject({
|
||||
method: "POST", url: "/scan-jobs/job/upload",
|
||||
headers: { ...f.headers, "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="scan.pdf"\r\nContent-Type: application/pdf\r\n\r\n%PDF-test\r\n--${boundary}--\r\n`,
|
||||
})
|
||||
assert.equal(response.statusCode, 200, response.body)
|
||||
assert.equal(f.saved[0][1], 7)
|
||||
assert.equal(f.saved[0][3].filename, "scan.pdf")
|
||||
assert.equal(f.saved[0][3].contentType, "application/pdf")
|
||||
assert.equal(f.saved[0][4], "folder")
|
||||
assert.equal(f.job.status, "completed")
|
||||
assert.equal(f.job.fileId, "saved-file")
|
||||
})
|
||||
133
backend/tests/instanceAgentMigration.test.ts
Normal file
133
backend/tests/instanceAgentMigration.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { execFile } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { readFile, mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { Pool } from "pg"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import { readMigrationFiles } from "drizzle-orm/migrator"
|
||||
import Fastify from "fastify"
|
||||
import routes from "../src/routes/instanceAgents"
|
||||
|
||||
const migrationsFolder = path.resolve(__dirname, "../db/migrations") + path.sep
|
||||
const repairTag = "0072_restore_instance_scan_agents"
|
||||
const journal = JSON.parse(readFileSync(migrationsFolder + "meta/_journal.json", "utf8")).entries
|
||||
const repair = journal.find((entry: any) => entry.tag === repairTag)
|
||||
const previousWatermark = Math.max(...journal.filter((entry: any) => entry.idx < repair?.idx).map((entry: any) => entry.when))
|
||||
const execute = promisify(execFile)
|
||||
|
||||
test("the scanner repair is registered after every previously shipped migration", () => {
|
||||
assert.ok(repair, "scanner repair must be in the Drizzle journal")
|
||||
assert.ok(repair.when > previousWatermark)
|
||||
const pending = readMigrationFiles({ migrationsFolder }).filter(entry => entry.folderMillis > previousWatermark)
|
||||
assert.ok(pending.some(entry => entry.sql.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS "instance_agents"'))))
|
||||
})
|
||||
|
||||
// Opt in with FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine. This creates its own
|
||||
// disposable database container; it never reads DATABASE_URL or application secrets.
|
||||
test("migration repairs upgraded databases and preserves manually installed scanners", {
|
||||
skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE,
|
||||
}, async t => {
|
||||
// Isolate this repair from unrelated future migrations. Registration in the
|
||||
// actual shipped journal is checked above; its timestamp remains unchanged.
|
||||
const testMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-migrations-"))
|
||||
t.after(() => rm(testMigrations, { recursive: true, force: true }))
|
||||
await mkdir(path.join(testMigrations, "meta"))
|
||||
await writeFile(path.join(testMigrations, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [repair] }))
|
||||
await writeFile(path.join(testMigrations, repairTag + ".sql"), await readFile(migrationsFolder + repairTag + ".sql"))
|
||||
const container = "fedeo-scan-migration-" + randomUUID()
|
||||
const password = randomUUID()
|
||||
await execute("docker", ["run", "--rm", "-d", "--name", container,
|
||||
"-e", "POSTGRES_PASSWORD=" + password, "-p", "127.0.0.1::5432",
|
||||
process.env.FEDEO_TEST_POSTGRES_IMAGE!])
|
||||
let admin: Pool | undefined
|
||||
t.after(async () => {
|
||||
try { await admin?.end() }
|
||||
finally { await execute("docker", ["rm", "-f", container]) }
|
||||
})
|
||||
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
|
||||
const port = Number(stdout.trim().split(":").at(-1))
|
||||
admin = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 })
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await admin.query("SELECT 1"); break } catch (error) {
|
||||
if (attempt >= 80) throw error
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of ["absent", "0051", "0052", "0053"]) {
|
||||
await t.test(state, async () => {
|
||||
const database = "scan_" + state
|
||||
await admin.query('CREATE DATABASE "' + database + '"')
|
||||
const pool = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database, max: 1 })
|
||||
const app = Fastify()
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE tenants (id bigint PRIMARY KEY);
|
||||
CREATE TABLE auth_users (id uuid PRIMARY KEY);
|
||||
CREATE TABLE files (id uuid PRIMARY KEY);
|
||||
INSERT INTO tenants VALUES (1);
|
||||
CREATE SCHEMA drizzle;
|
||||
CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);
|
||||
`)
|
||||
// An existing installation already passed 0071. Re-inserting old
|
||||
// journal entries 0051-0053 would therefore NOT fix this database.
|
||||
await pool.query("INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('previous-release', $1)", [previousWatermark])
|
||||
if (state !== "absent") {
|
||||
for (const file of ["0051_instance_scan_agents", "0052_instance_agent_scan_defaults", "0053_instance_agent_postprocess_defaults"]) {
|
||||
if (file.slice(0, 4) <= state) await pool.query(await readFile(migrationsFolder + file + ".sql", "utf8"))
|
||||
}
|
||||
await pool.query(`
|
||||
INSERT INTO instance_agents (id, name, token_prefix, token_hash)
|
||||
VALUES ('00000000-0000-4000-8000-000000000001', 'Existing scanner', 'prefix', 'existing-hash');
|
||||
INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id, status, settings)
|
||||
VALUES (1, '00000000-0000-4000-8000-000000000001', 'completed', '{"resolution":600}');
|
||||
`)
|
||||
if (state !== "0051") {
|
||||
await pool.query(`UPDATE instance_agents SET preferred_scanner_name='existing-device', scan_defaults='{"resolution":600}'`)
|
||||
}
|
||||
}
|
||||
|
||||
const db = drizzle(pool)
|
||||
await migrate(db, { migrationsFolder: testMigrations })
|
||||
await migrate(db, { migrationsFolder: testMigrations })
|
||||
// Also prove the repair SQL itself is safe after manual application.
|
||||
await pool.query(await readFile(migrationsFolder + repairTag + ".sql", "utf8"))
|
||||
app.decorate("db", db)
|
||||
app.addHook("onRequest", async req => { req.user = { is_admin: true } as any })
|
||||
await app.register(routes)
|
||||
const listing = await app.inject({ method: "GET", url: "/instance-agents" })
|
||||
assert.equal(listing.statusCode, 200, listing.body)
|
||||
const agents = listing.json().agents
|
||||
assert.equal(agents.length, state === "absent" ? 0 : 1)
|
||||
if (state !== "absent") {
|
||||
assert.equal(agents[0].name, "Existing scanner")
|
||||
assert.equal((await pool.query("SELECT token_hash FROM instance_agents")).rows[0].token_hash, "existing-hash")
|
||||
assert.equal((await pool.query("SELECT status, settings FROM instance_agent_scan_jobs")).rows[0].settings.resolution, 600)
|
||||
if (state !== "0051") {
|
||||
assert.equal(agents[0].preferredScannerName, "existing-device")
|
||||
assert.deepEqual(agents[0].scanDefaults, { resolution: 600 })
|
||||
}
|
||||
}
|
||||
const created = await app.inject({ method: "POST", url: "/instance-agents", payload: { name: "New scanner" } })
|
||||
assert.equal(created.statusCode, 200, created.body)
|
||||
assert.equal(created.json().agent.scanDefaults.postprocess, false)
|
||||
assert.equal(created.json().agent.scanDefaults.postprocessProfile, "document")
|
||||
assert.equal(created.json().agent.scanDefaults.format, "pdf")
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE created_at = $1", [repair.when])).rows[0].count, 1)
|
||||
const indexes = (await pool.query("SELECT indexname FROM pg_indexes WHERE tablename='instance_agent_scan_jobs'")).rows.map(row => row.indexname)
|
||||
assert.ok(indexes.includes("instance_agent_scan_jobs_agent_status_idx"))
|
||||
assert.ok(indexes.includes("instance_agent_scan_jobs_tenant_idx"))
|
||||
await assert.rejects(pool.query("INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id) VALUES (999, $1)", [created.json().agent.id]), { code: "23503" })
|
||||
} finally {
|
||||
await app.close()
|
||||
await pool.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
57
backend/tests/instanceAgentSetup.test.ts
Normal file
57
backend/tests/instanceAgentSetup.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { createHash } from "node:crypto"
|
||||
import Fastify from "fastify"
|
||||
import { PgDialect } from "drizzle-orm/pg-core"
|
||||
import routes from "../src/routes/instanceAgents"
|
||||
|
||||
const id = "b48c0e33-1abd-4b11-89fa-1bb7b39a3057"
|
||||
async function fixture(isAdmin: boolean) {
|
||||
const updates: any[] = []
|
||||
const app = Fastify()
|
||||
app.addHook("onRequest", async (request) => { request.user = { is_admin: isAdmin } as any })
|
||||
app.decorate("db", {
|
||||
update: () => ({ set: (data: any) => ({ where: (condition: any) => ({ returning: async () => {
|
||||
if (new PgDialect().sqlToQuery(condition).params[0] !== id) return []
|
||||
updates.push(data)
|
||||
return [{ id }]
|
||||
} }) }) }),
|
||||
})
|
||||
await app.register(routes)
|
||||
return { app, updates }
|
||||
}
|
||||
|
||||
test("only administrators can rotate an agent token", async (t) => {
|
||||
const f = await fixture(false)
|
||||
t.after(() => f.app.close())
|
||||
const response = await f.app.inject({ method: "POST", url: "/instance-agents/" + id + "/token" })
|
||||
assert.equal(response.statusCode, 403)
|
||||
assert.equal(f.updates.length, 0)
|
||||
})
|
||||
|
||||
test("rotation returns a new token, stores only its hash and requires a new heartbeat", async (t) => {
|
||||
const f = await fixture(true)
|
||||
t.after(() => f.app.close())
|
||||
const tokens = []
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = await f.app.inject({ method: "POST", url: "/instance-agents/" + id + "/token" })
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.equal(response.headers["cache-control"], "no-store")
|
||||
const { token } = response.json()
|
||||
assert.match(token, /^fedeo_agent_[a-f0-9]{64}$/)
|
||||
assert.equal(f.updates[i].tokenHash, createHash("sha256").update(token).digest("hex"))
|
||||
assert.equal(f.updates[i].tokenPrefix, token.slice(0, 24))
|
||||
assert.equal(f.updates[i].lastSeenAt, null)
|
||||
assert.equal(f.updates[i].token, undefined)
|
||||
tokens.push(token)
|
||||
}
|
||||
assert.notEqual(tokens[0], tokens[1])
|
||||
})
|
||||
|
||||
test("unknown agents cannot receive setup credentials", async (t) => {
|
||||
const f = await fixture(true)
|
||||
t.after(() => f.app.close())
|
||||
const response = await f.app.inject({ method: "POST", url: "/instance-agents/00000000-0000-4000-8000-000000000000/token" })
|
||||
assert.equal(response.statusCode, 404)
|
||||
assert.equal(f.updates.length, 0)
|
||||
})
|
||||
37
backend/tests/mcpArchivedResults.test.ts
Normal file
37
backend/tests/mcpArchivedResults.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { mcpTools } from "../src/mcp/registry"
|
||||
import { asToolResult } from "../src/mcp/result"
|
||||
|
||||
test("removes archived records from MCP text and structured output", () => {
|
||||
const result = asToolResult({
|
||||
rows: [
|
||||
{ id: 1, archived: false, name: "Aktiv" },
|
||||
{ id: 2, archived: true, name: "Archiviert" },
|
||||
],
|
||||
nested: {
|
||||
current: { id: 3, archived: false },
|
||||
previous: { id: 4, archived: true },
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(result.structuredContent, {
|
||||
rows: [{ id: 1, archived: false, name: "Aktiv" }],
|
||||
nested: { current: { id: 3, archived: false } },
|
||||
})
|
||||
assert.deepEqual(JSON.parse(result.content[0].text), result.structuredContent)
|
||||
})
|
||||
|
||||
test("does not expose a singular archived record", () => {
|
||||
const result = asToolResult({ task: { id: 1, archived: true } })
|
||||
|
||||
assert.deepEqual(result.structuredContent, {})
|
||||
assert.deepEqual(JSON.parse(result.content[0].text), {})
|
||||
})
|
||||
|
||||
test("does not advertise includeArchived on MCP tools", () => {
|
||||
for (const tool of mcpTools) {
|
||||
const properties = (tool.inputSchema.properties || {}) as Record<string, unknown>
|
||||
assert.equal(properties.includeArchived, undefined, tool.name)
|
||||
}
|
||||
})
|
||||
245
backend/tests/mcpMasterdata.test.ts
Normal file
245
backend/tests/mcpMasterdata.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { getTableColumns, getTableName } from "drizzle-orm"
|
||||
import { PgDialect } from "drizzle-orm/pg-core"
|
||||
import * as schema from "../db/schema"
|
||||
import { mcpToolMap } from "../src/mcp/registry"
|
||||
import { masterdataWriteConfigs, prepareMasterdataInput } from "../src/mcp/tools/masterdataWrites"
|
||||
import { asToolResult } from "../src/mcp/result"
|
||||
|
||||
const tenantId = 7
|
||||
const userId = "00000000-0000-4000-8000-000000000001"
|
||||
const parentId = "00000000-0000-4000-8000-000000000002"
|
||||
const childId = "00000000-0000-4000-8000-000000000003"
|
||||
const otherId = "00000000-0000-4000-8000-000000000004"
|
||||
const dialect = new PgDialect()
|
||||
|
||||
/** Transactional fixture which evaluates actual Drizzle WHERE clauses, including tenant filters. */
|
||||
function fixture(initial: Record<string, any[]> = {}) {
|
||||
let state = structuredClone(initial)
|
||||
const statements: string[] = []
|
||||
let failHistory = false
|
||||
const matches = (table: any, where: any, row: any): boolean => {
|
||||
if (!where) return true
|
||||
const query = dialect.sqlToQuery(where)
|
||||
statements.push(query.sql)
|
||||
const columns = getTableColumns(table) as Record<string, any>
|
||||
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
|
||||
const predicates = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)]
|
||||
assert.ok(predicates.length, `Unimplemented fixture predicate: ${query.sql}`)
|
||||
return predicates.every((match) => {
|
||||
const value = row[keyFor(match[1])]
|
||||
if (match[2]) return value === query.params[Number(match[2]) - 1]
|
||||
return [...match[3].matchAll(/\$(\d+)/g)].some((parameter) => value === query.params[Number(parameter[1]) - 1])
|
||||
})
|
||||
}
|
||||
const rowsFor = (table: any) => state[getTableName(table)] ||= []
|
||||
const db: any = {
|
||||
execute: async () => undefined,
|
||||
transaction: async (callback: any) => {
|
||||
const before = structuredClone(state)
|
||||
try { return await callback(db) } catch (error) { state = before; throw error }
|
||||
},
|
||||
select: (selection?: Record<string, any>) => ({ from: (table: any) => {
|
||||
let condition: any
|
||||
let limit = Infinity
|
||||
const query: any = {
|
||||
where: (where: any) => { condition = where; return query },
|
||||
limit: (value: number) => { limit = value; return query },
|
||||
for: () => query,
|
||||
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
|
||||
const rows = rowsFor(table).filter((row) => matches(table, condition, row)).slice(0, limit)
|
||||
const columns = getTableColumns(table)
|
||||
return rows.map((row) => selection ? Object.fromEntries(Object.entries(selection).map(([key, column]) => {
|
||||
const field = Object.keys(columns).find((name) => columns[name] === column)!
|
||||
return [key, row[field]]
|
||||
})) : { ...row })
|
||||
}).then(resolve, reject),
|
||||
}
|
||||
return query
|
||||
} }),
|
||||
insert: (table: any) => ({ values: (data: any) => {
|
||||
let completed = false
|
||||
let created: any
|
||||
const run = () => {
|
||||
if (!completed) {
|
||||
if (failHistory && table === schema.historyitems) throw new Error("History unavailable")
|
||||
const id = table === schema.costcentres ? otherId : Math.max(0, ...rowsFor(table).map((row) => typeof row.id === "number" ? row.id : 0)) + 1
|
||||
created = { id, archived: false, ...data }
|
||||
rowsFor(table).push(created)
|
||||
completed = true
|
||||
}
|
||||
return [created]
|
||||
}
|
||||
return { returning: async () => run(), then: (resolve: any, reject: any) => Promise.resolve().then(run).then(resolve, reject) }
|
||||
} }),
|
||||
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => {
|
||||
let result: any[] | undefined
|
||||
const run = () => {
|
||||
if (!result) result = rowsFor(table).filter((row) => matches(table, where, row)).map((row) => { Object.assign(row, data); return { ...row } })
|
||||
return result
|
||||
}
|
||||
return { returning: async () => run(), then: (resolve: any, reject: any) => Promise.resolve().then(run).then(resolve, reject) }
|
||||
} }) }),
|
||||
}
|
||||
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
|
||||
return { context, rows: (table: any) => rowsFor(table), statements, failHistory: () => { failHistory = true } }
|
||||
}
|
||||
const call = async (context: any, name: string, args: Record<string, any>) => {
|
||||
const tool = mcpToolMap.get(`masterdata.${name}`)
|
||||
assert.ok(tool, name)
|
||||
return await tool.handler(context, args) as any
|
||||
}
|
||||
|
||||
for (const config of masterdataWriteConfigs) {
|
||||
test(`${config.namespace}: create, update and archive are scoped and attribute changes to the actor`, async () => {
|
||||
const f = fixture({ units: [{ id: 1 }], services: [], products: [], hourrates: [] })
|
||||
const input: any = config.resource === "contacts" ? { firstName: "Ada", lastName: "Lovelace" } : { name: "Test" }
|
||||
if (config.numberField) input[config.numberField] = "MCP-100"
|
||||
if (config.resource === "products") input.unit = 1
|
||||
const created = await call(f.context, `${config.namespace}.create`, input)
|
||||
const record = created[config.resultKey]
|
||||
assert.equal(record.tenant, tenantId)
|
||||
assert.equal(record.archived, false)
|
||||
assert.equal(record.updatedBy || record.updated_by, userId)
|
||||
const update = config.resource === "contacts" ? { firstName: "Grace", lastName: "Hopper" } : { name: "Changed" }
|
||||
const updated = await call(f.context, `${config.namespace}.update`, { id: record.id, ...update })
|
||||
assert.equal(updated[config.resultKey][config.resource === "contacts" ? "fullName" : "name"], config.resource === "contacts" ? "Grace Hopper" : "Changed")
|
||||
const confirmation = await call(f.context, `${config.namespace}.archive`, { id: record.id })
|
||||
assert.deepEqual(asToolResult(confirmation).structuredContent, { id: record.id, success: true, action: "archived" })
|
||||
assert.equal(f.rows(config.table)[0].archived, true)
|
||||
await assert.rejects(call(f.context, `${config.namespace}.update`, { id: record.id, ...update }), /archiviert/)
|
||||
})
|
||||
|
||||
test(`${config.namespace}: rejects missing permissions, foreign records and injected system fields`, async () => {
|
||||
const id = config.resource === "costcentres" ? parentId : 1
|
||||
const f = fixture({ [getTableName(config.table)]: [{ id, tenant: 8, archived: false, name: "Other tenant" }] })
|
||||
const fields = config.resource === "contacts" ? { firstName: "Changed" } : { name: "Changed" }
|
||||
await assert.rejects(call(f.context, `${config.namespace}.update`, { id, ...fields }), /nicht gefunden/)
|
||||
await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /nicht gefunden/)
|
||||
assert.equal(f.rows(config.table)[0].archived, false)
|
||||
for (const key of ["tenant", "createdAt", "updatedBy", "updated_by", "archived"]) {
|
||||
await assert.rejects(call(f.context, `${config.namespace}.update`, { id, [key]: "injected" }), /unbekanntes Feld/)
|
||||
}
|
||||
f.context.isAdmin = false
|
||||
f.context.permissions = [`masterdata.${config.namespace}.read`]
|
||||
await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /Fehlende Berechtigung/)
|
||||
f.context.permissions.push(`masterdata.${config.namespace}.write`)
|
||||
await assert.rejects(call(f.context, `${config.namespace}.archive`, { id }), /nicht gefunden/)
|
||||
})
|
||||
}
|
||||
|
||||
test("numbered resources allocate numbers, skip occupied values and reject duplicates", async () => {
|
||||
for (const config of masterdataWriteConfigs.filter((item) => item.numberField)) {
|
||||
const f = fixture({
|
||||
tenants: [{ id: tenantId, numberRanges: { [config.resource]: { prefix: "K-", nextNumber: 1 } } }],
|
||||
[getTableName(config.table)]: [{ id: config.resource === "costcentres" ? parentId : 10, tenant: tenantId, [config.numberField!]: "K-1", archived: true }],
|
||||
})
|
||||
const result = await call(f.context, `${config.namespace}.create`, { name: "New" })
|
||||
assert.equal(result[config.resultKey][config.numberField!], "K-2")
|
||||
assert.equal(f.rows(schema.tenants)[0].numberRanges[config.resource].nextNumber, 3)
|
||||
await assert.rejects(call(f.context, `${config.namespace}.create`, { name: "Duplicate", [config.numberField!]: "K-1" }), /bereits vergeben/)
|
||||
}
|
||||
})
|
||||
|
||||
test("cost centre hierarchy rejects self, descendants, foreign and archived parents", async () => {
|
||||
const f = fixture({ costcentres: [
|
||||
{ id: parentId, tenant: tenantId, name: "Parent", number: "1", parentCostcentre: null, archived: false },
|
||||
{ id: childId, tenant: tenantId, name: "Child", number: "2", parentCostcentre: parentId, archived: false },
|
||||
{ id: otherId, tenant: 8, name: "Foreign", archived: false },
|
||||
] })
|
||||
await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: parentId }), /sich selbst/)
|
||||
await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: childId }), /Zyklus/)
|
||||
await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: otherId }), /nicht verfügbar/)
|
||||
f.rows(schema.costcentres)[1].archived = true
|
||||
await assert.rejects(call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: childId }), /nicht verfügbar/)
|
||||
const result = await call(f.context, "cost_centres.update", { id: parentId, parentCostcentre: null })
|
||||
assert.equal(result.costCentre.parentCostcentre, null)
|
||||
assert.equal(f.rows(schema.costcentres)[1].parentCostcentre, parentId)
|
||||
})
|
||||
|
||||
test("all declared relationships reject foreign-tenant references before insert", async () => {
|
||||
for (const config of masterdataWriteConfigs) {
|
||||
for (const reference of config.references || []) {
|
||||
if (reference.table === schema.units) continue
|
||||
const refId = (reference.table[reference.idKey || "id"].columnType === "PgUUID") ? parentId : 99
|
||||
const initial = { [getTableName(reference.table)]: [{ [reference.idKey || "id"]: refId, [reference.tenantKey || "tenant"]: 8, archived: false, active: true }], units: [{ id: 1 }] }
|
||||
const f = fixture(initial)
|
||||
const input: any = config.resource === "contacts" ? { fullName: "Contact" } : { name: "Record" }
|
||||
if (config.resource === "products") input.unit = 1
|
||||
if (config.numberField) input[config.numberField] = "N-1"
|
||||
const parts = reference.field.split(".")
|
||||
if (parts.length === 1) {
|
||||
input[parts[0]] = ["profiles", "productcategories", "servicecategories"].includes(parts[0]) ? [refId] : refId
|
||||
} else if (parts[0] === "infoData") input.infoData = { bankAccountIds: [refId] }
|
||||
else input[parts[0]] = [{ [parts[1]]: refId }]
|
||||
await assert.rejects(call(f.context, `${config.namespace}.create`, input), /nicht verfügbar/, `${config.namespace}.${reference.field}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("partial updates preserve unrelated fields and nullable links can be cleared", async () => {
|
||||
const f = fixture({ customers: [{ id: 1, tenant: tenantId, name: "Existing", archived: false, customerNumber: "C-1", infoData: { email: "old@example.org", city: "Berlin" } }] })
|
||||
const result = await call(f.context, "customers.update", { id: 1, infoData: { email: "new@example.org" }, notes: null })
|
||||
assert.deepEqual(result.customer.infoData, { email: "new@example.org", city: "Berlin" })
|
||||
assert.equal(result.customer.name, "Existing")
|
||||
assert.equal(result.customer.notes, null)
|
||||
})
|
||||
|
||||
test("failed history write rolls back record creation and number range consumption", async () => {
|
||||
const f = fixture({ tenants: [{ id: tenantId, numberRanges: { customers: { prefix: "C-", nextNumber: 1 } } }] })
|
||||
f.failHistory()
|
||||
await assert.rejects(call(f.context, "customers.create", { name: "Rollback" }), /History unavailable/)
|
||||
assert.equal(f.rows(schema.customers).length, 0)
|
||||
assert.equal(f.rows(schema.tenants)[0].numberRanges.customers.nextNumber, 1)
|
||||
})
|
||||
|
||||
test("product prices propagate to composed services in the same transaction", async () => {
|
||||
const f = fixture({
|
||||
products: [{ id: 1, tenant: tenantId, name: "Product", unit: 1, selling_price: 10, purchase_price: 5, archived: false }],
|
||||
services: [{ id: 2, tenant: tenantId, name: "Service", archived: false, materialComposition: [{ product: 1, quantity: 2 }], personalComposition: [], priceUpdateLocked: false }],
|
||||
hourrates: [],
|
||||
})
|
||||
await call(f.context, "products.update", { id: 1, selling_price: 20 })
|
||||
assert.equal(f.rows(schema.services)[0].sellingPriceComposed.total, 40)
|
||||
assert.equal(f.rows(schema.services)[0].purchasePriceComposed.total, 10)
|
||||
})
|
||||
|
||||
test("validates scalar types, required values, dates and empty updates", () => {
|
||||
const config = (name: string) => masterdataWriteConfigs.find((item) => item.namespace === name)!
|
||||
assert.throws(() => prepareMasterdataInput(config("products"), "create", { name: "Article" }), /unit ist erforderlich/)
|
||||
assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: "invalid", name: "Cost centre" }), /UUID/)
|
||||
assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: parentId, name: " " }), /nicht leer/)
|
||||
assert.throws(() => prepareMasterdataInput(config("customers"), "update", { id: 1 }), /Mindestens ein/)
|
||||
for (const active of ["false", 0, null]) assert.throws(() => prepareMasterdataInput(config("customers"), "update", { id: 1, active }), /Boolean/)
|
||||
for (const birthday of ["2026-02-30", "not a date"]) assert.throws(() => prepareMasterdataInput(config("contacts"), "update", { id: 1, birthday }), /Datum|Kalenderdatum/)
|
||||
assert.throws(() => prepareMasterdataInput(config("products"), "update", { id: 1, selling_price: "20" }), /Zahl/)
|
||||
assert.throws(() => prepareMasterdataInput(config("cost_centres"), "update", { id: parentId, profiles: ["invalid"] }), /UUID/)
|
||||
})
|
||||
|
||||
test("manual service price updates replace the previous composed total, including zero", async () => {
|
||||
const f = fixture({ services: [{
|
||||
id: 1, tenant: tenantId, name: "Manual", archived: false, sellingPrice: 10,
|
||||
sellingPriceComposed: { total: 10 }, materialComposition: [], personalComposition: [],
|
||||
}], products: [], hourrates: [] })
|
||||
for (const price of [20, 0]) {
|
||||
const result = await call(f.context, "services.update", { id: 1, sellingPrice: price })
|
||||
assert.equal(result.service.sellingPrice, price)
|
||||
assert.equal(result.service.sellingPriceComposed.total, price)
|
||||
}
|
||||
await assert.rejects(call(f.context, "services.update", { id: 1, sellingPrice: 20, sellingPriceComposed: { total: 30 } }), /widersprechen/)
|
||||
})
|
||||
|
||||
for (const config of masterdataWriteConfigs.filter((item) => item.addGet)) {
|
||||
test(`${config.namespace}.get: returns only an active record from the current tenant`, async () => {
|
||||
const f = fixture({ [getTableName(config.table)]: [
|
||||
{ id: 1, tenant: tenantId, archived: false, name: "Active" },
|
||||
{ id: 2, tenant: 8, archived: false, name: "Foreign" },
|
||||
{ id: 3, tenant: tenantId, archived: true, name: "Archived" },
|
||||
] })
|
||||
assert.equal((await call(f.context, `${config.namespace}.get`, { id: 1 }))[config.resultKey].name, "Active")
|
||||
for (const id of [2, 3]) await assert.rejects(call(f.context, `${config.namespace}.get`, { id }), /nicht gefunden/)
|
||||
f.context.isAdmin = false
|
||||
await assert.rejects(call(f.context, `${config.namespace}.get`, { id: 1 }), /Fehlende Berechtigung/)
|
||||
})
|
||||
}
|
||||
63
backend/tests/mcpPagination.test.ts
Normal file
63
backend/tests/mcpPagination.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { getTableName } from "drizzle-orm"
|
||||
import { drizzle } from "drizzle-orm/pg-proxy"
|
||||
import * as schema from "../db/schema"
|
||||
import { mcpTools } from "../src/mcp/registry"
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult } from "../src/mcp/pagination"
|
||||
|
||||
const tables = Object.values(schema).filter((value: any) => {
|
||||
try { return typeof getTableName(value) === "string" } catch { return false }
|
||||
})
|
||||
|
||||
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset)) {
|
||||
test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => {
|
||||
const db = drizzle(async (sql, params) => {
|
||||
if (sql.includes('from "tenants"')) return { rows: [["skr03"]] }
|
||||
const tableName = sql.match(/from "(\w+)"/)?.[1]
|
||||
const table = tables.find((table: any) => getTableName(table) === tableName) as any
|
||||
assert.ok(table, sql)
|
||||
assert.match(sql, /order by .*"id"/)
|
||||
if (table.tenant || table.tenantId) {
|
||||
assert.match(sql, /where .*"tenant(?:_id)?" = \$\d+/)
|
||||
assert.ok(params.includes(7), "active tenant must be bound")
|
||||
}
|
||||
if (table.archived) assert.match(sql, /"archived" = \$\d+/)
|
||||
const limitParam = sql.match(/limit \$(\d+)/)
|
||||
const offsetParam = sql.match(/offset \$(\d+)/)
|
||||
assert.ok(limitParam, "query must be bounded")
|
||||
const limit = Number(params[Number(limitParam[1]) - 1])
|
||||
const offset = offsetParam ? Number(params[Number(offsetParam[1]) - 1]) : 0
|
||||
assert.equal(limit, 101, "fetch one extra row for hasMore")
|
||||
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
|
||||
const idIndex = selected.findIndex((column) => /"id"$/.test(column))
|
||||
assert.ok(idIndex >= 0)
|
||||
return { rows: Array.from({ length: Math.min(limit, Math.max(0, 205 - offset)) }, (_, i) => {
|
||||
const values = selected.map(() => null as unknown)
|
||||
values[idIndex] = offset + i + 1
|
||||
return values
|
||||
}) }
|
||||
}, { schema })
|
||||
const context = { server: { db }, tenantId: 7, userId: "test" } as any
|
||||
const ids: unknown[] = []
|
||||
let offset = 0
|
||||
for (let page = 0; page < 3; page++) {
|
||||
const result: any = await tool.handler(context, { id: 1, limit: 100, offset })
|
||||
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
||||
assert.equal(result.pagination.hasMore, page < 2)
|
||||
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
||||
ids.push(...result.rows.map((row: any) => Number(row.id)))
|
||||
offset = result.pagination.nextOffset
|
||||
}
|
||||
assert.deepEqual(ids, Array.from({ length: 205 }, (_, i) => i + 1))
|
||||
})
|
||||
}
|
||||
|
||||
test("pagination validates input and handles empty and exactly full final pages", () => {
|
||||
for (const limit of [-1, 0, 101, 1.5, "10", Infinity]) assert.throws(() => limitFromArgs({ limit }))
|
||||
for (const offset of [-1, 1.5, "10", Infinity]) assert.throws(() => offsetFromArgs({ offset }))
|
||||
assert.equal(limitFromArgs({}), 25)
|
||||
assert.equal(limitFromArgs({}, 50), 50)
|
||||
assert.equal(paginatedResult([1, 2], { limit: 2 }).pagination.nextOffset, null)
|
||||
assert.deepEqual(paginatedResult([], { offset: 300 }).pagination, { limit: 25, offset: 300, hasMore: false, nextOffset: null })
|
||||
})
|
||||
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { mcpToolMap } from "../src/mcp/registry"
|
||||
|
||||
test("publishes the complete serial invoice configuration on create and update", () => {
|
||||
for (const name of ["accounting.outgoing_documents.create", "accounting.outgoing_documents.update"]) {
|
||||
const tool = mcpToolMap.get(name)
|
||||
const properties = tool?.inputSchema.properties as Record<string, any>
|
||||
|
||||
assert.ok(properties.serialConfig, name)
|
||||
assert.deepEqual(properties.serialConfig.properties.intervall.enum, [
|
||||
"wöchentlich",
|
||||
"2 - wöchentlich",
|
||||
"monatlich",
|
||||
"vierteljährlich",
|
||||
"halbjährlich",
|
||||
"jährlich",
|
||||
])
|
||||
assert.deepEqual(properties.serialConfig.properties.dateDirection.enum, ["Rückwirkend", "Im Voraus"])
|
||||
assert.ok(properties.type.enum.includes("serialInvoices"))
|
||||
}
|
||||
})
|
||||
|
||||
test("registers tenant-scoped serial invoice execution tools", () => {
|
||||
const execute = mcpToolMap.get("accounting.serial_invoices.execute")
|
||||
const list = mcpToolMap.get("accounting.serial_invoice_executions.list")
|
||||
const finish = mcpToolMap.get("accounting.serial_invoice_executions.finish")
|
||||
|
||||
assert.deepEqual(execute?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||
assert.deepEqual(execute?.inputSchema.required, ["executionDate", "templateIds"])
|
||||
assert.deepEqual(list?.requiredPermissions, ["accounting.outgoing_documents.read"])
|
||||
assert.deepEqual(finish?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||
assert.deepEqual(finish?.inputSchema.required, ["id"])
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user