33 Commits

Author SHA1 Message Date
7e9f4409f6 Merge pull request 'dev' (#226) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 33s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
Reviewed-on: #226
2026-09-23 14:57:40 +00:00
0ff74cdcdf feat(selfhost): add verified release updates with backups and recovery state
Some checks failed
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Failing after 4m59s
2026-09-23 16:52:00 +02:00
f51f2b13a1 fix(selfhost): pin MinIO images and add application health checks 2026-09-23 16:39:17 +02:00
2e63af1aa2 Merge pull request 'fix(release): stream large migration checksums from git' (#225) from flfeders/codex/release-migration-checksums into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m59s
Reviewed-on: #225
2026-09-23 14:09:57 +00:00
74c0cf40b3 fix(release): stream large migration checksums from git
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m50s
2026-09-23 15:37:19 +02:00
5ebd2d508c Merge pull request 'fix(release): exclude legacy main history from commit checks' (#224) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 5m57s
Reviewed-on: #224
2026-09-23 13:03:36 +00:00
f6996c1d7b fix(release): exclude legacy main history from commit checks
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 6m4s
2026-09-20 18:44:53 +02:00
9128fbb165 Merge pull request 'fix(ci): build dev images without repository write access' (#221) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 26s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #221
2026-09-20 16:25:38 +00:00
bb94e73229 fix(ci): build dev images without repository write access
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m54s
2026-09-20 18:22:17 +02:00
8473221c13 Merge pull request '3.0.0' (#220) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 22s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #220
2026-09-20 16:10:46 +00:00
68c0a224e5 feat(scan): add guided Linux agent setup in administration
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 29s
2026-09-17 18:56:30 +02:00
1329f164d8 chore(release): start FEDEO releases at 3.0.0 2026-09-17 18:46:51 +02:00
92859f3ccf feat(release): automate versioned releases on main 2026-09-17 18:41:53 +02:00
dd37622bb6 feat(scan): support iX1600 duplex scanning on Linux 2026-09-17 18:22:43 +02:00
cfef07b96d feat(mcp): manage master data and cost centre hierarchies 2026-09-14 13:29:25 +02:00
342ad9764e feat(mcp): paginate record lists and describe document inputs 2026-09-14 13:17:49 +02:00
58fc252110 fix: make project number editable 2026-09-14 13:09:57 +02:00
0c430e993e feat: configure planning board week end 2026-09-11 19:31:16 +02:00
a50ff256ee fix(accounting): validate serial invoice templates
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 43s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
Build and Push Docker Images / build-website (push) Successful in 24s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
2026-09-09 14:38:24 +02:00
ebbd1e0cd0 feat(accounting): edit finalized document cost centres 2026-09-09 14:37:22 +02:00
2ec162c0e7 feat(mcp): support serial invoice workflows 2026-09-09 14:30:42 +02:00
d0bc8a5e0f fix(mcp): exclude archived records 2026-09-09 14:19:34 +02:00
a6f55b61e4 fix: Einheit in Artikelansicht laden
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 22s
Build and Push Docker Images / build-website (push) Successful in 22s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
2026-09-09 14:00:34 +02:00
c383a291e1 Kundeninventar-Nummern kollisionsfrei vergeben
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 21s
Build and Push Docker Images / build-website (push) Successful in 21s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s
2026-09-09 13:34:14 +02:00
0eadf42973 fix: Nummernkreise beim Öffnen aktualisieren
All checks were successful
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-docs (push) Successful in 21s
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 21s
2026-09-09 13:10:50 +02:00
e7554fa2cc .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:42:28 +00:00
7c1fabf58a .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:40:58 +00:00
1203b6cbd1 .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 11:39:03 +00:00
525f2906fb .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2026-01-15 11:38:50 +00:00
b105382abf .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 11:38:00 +00:00
b1cdec7d17 Merge pull request 'Added feature request template' (#62) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #62
2026-01-15 11:31:57 +00:00
f1d512b2e5 Merge pull request 'dev' (#61) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #61
2026-01-15 11:29:15 +00:00
db21b43120 Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #40
2026-01-08 22:21:06 +00:00
113 changed files with 12979 additions and 891 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
**/node_modules
**/.nuxt
**/.output
**/dist
**/.env
**/.env.*
!**/.env.example
.release
gmail-puett

View File

@@ -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

View File

@@ -2,37 +2,18 @@
name: 🐛 Bug Report
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
title: '[BUG] '
labels: bug
labels: Problem
assignees: ''
---
**Beschreibung**
Eine klare und prägnante Beschreibung des Fehlers.
**Reproduktion**
Schritte, um den Fehler zu reproduzieren:
Entweder:
1. Gehe zu '...'
2. Klicke auf '...'
3. Scrolle runter zu '...'
4. Siehe Fehler
Oder Link zur Seite
**Erwartetes Verhalten**
Eine klare Beschreibung dessen, was du erwartet hast.
**Screenshots**
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
**Umgebung:**
- Betriebssystem: [z.B. Windows, macOS, Linux]
- Browser / Version (falls relevant): [z.B. Chrome 120]
- Projekt-Version: [z.B. v1.0.2]
**Zusätzlicher Kontext**
Füge hier alle anderen Informationen zum Problem hinzu.

View File

@@ -2,19 +2,16 @@
name: ✨ Feature Request
about: Schlage eine Idee für dieses Projekt vor.
title: '[FEATURE] '
labels: enhancement
labels: Funktionswunsch
assignees: ''
---
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
**Lösungsvorschlag**
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
**Alternativen**
Hast du über alternative Lösungen oder Workarounds nachgedacht?
**Zusätzlicher Kontext**
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.

View File

@@ -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
View File

@@ -2,6 +2,8 @@
node_modules/
.nuxt/
.output/
/.release/
__pycache__/
# Lokale Runtime-Daten und generierte Konfigurationen
matrix/postgres/

21
AGENTS.md Normal file
View 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
View 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.

View File

@@ -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:
@@ -479,49 +487,25 @@ 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:
```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
- `./traefik/letsencrypt/acme.json`
- deine `.env`

View File

@@ -4,3 +4,5 @@ dist
.env
*.log
*.tmp
__pycache__
tests

View File

@@ -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=

View File

@@ -4,3 +4,4 @@ node_modules
.env
*.log
*.tmp
__pycache__/

View File

@@ -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

View File

@@ -4,6 +4,28 @@ 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.
## Voraussetzungen
### macOS
@@ -13,6 +35,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 +47,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 +114,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 +143,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 +182,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:

View File

@@ -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:

View 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"
}
}
}

View File

@@ -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": {},

View 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}`)
}

View 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

View 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()

View File

@@ -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)
}
}

View File

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

View File

@@ -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(),

View File

@@ -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))

View File

@@ -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,
}
}

View File

@@ -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,
]
if (source) args.push("--source", source)
if (scannerName) args.push("--device-name", scannerName)
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)
}
// 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}`)
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),
// 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,
})
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.")
}
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}.`)
}
}

View File

@@ -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[]
}

View 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)
})
}

View 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)
})

View 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()

View File

@@ -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
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.env
.env.*
!.env.example
*.log

View File

@@ -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

View File

@@ -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",

View File

@@ -98,10 +98,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']

View 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,
}

View 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)]
}))
}

View 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 },
}
}

View File

@@ -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 {
},
}
}

View File

@@ -1,3 +1,5 @@
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 {
@@ -10,12 +12,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 +29,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 +43,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",
@@ -437,8 +453,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 +470,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 +502,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 +518,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 +540,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 +573,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 +596,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 +632,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 +718,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 +753,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 +877,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 +907,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 +924,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 +933,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 +965,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 +982,7 @@ export const accountingTools: McpTool[] = [
required: ["id"],
properties: {
id: { type: "number" },
...paginationProperties,
},
},
async handler(context, args) {
@@ -826,9 +997,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 +1315,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 +1323,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 +1355,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 +1372,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 +1382,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)
},
},
{

View File

@@ -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)
},
},
]

View 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
})

View File

@@ -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")

View File

@@ -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)
},
},
{

View File

@@ -24,15 +24,25 @@ 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",

View File

@@ -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})`)

View 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
}

View 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
}

View File

@@ -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;
}
}

View File

@@ -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) => {

View File

@@ -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()");

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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
}

View 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",
};
}

View File

@@ -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 =
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 || "") +
current.nextNumber +
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,
},
}

View File

@@ -147,6 +147,7 @@ export const resourceConfig = {
},
products: {
table: products,
mtoLoad: ["unit"],
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
},
productcategories: {

View 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;
}
});

View 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")
})

View 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)
})

View 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)
}
})

View 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/)
})
}

View 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 })
})

View 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"])
})

View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict"
import test from "node:test"
import { useNextNumberRangeNumber } from "../src/utils/functions"
test("number range skips identifiers that are already in use", async () => {
const tenant = {
id: 7,
numberRanges: {
customerinventoryitems: {
prefix: "KIA-",
suffix: "",
nextNumber: 1000,
},
},
}
let savedNumberRanges: any = null
const tx = {
execute: async () => undefined,
select: () => ({
from: () => ({
where: async () => [tenant],
}),
}),
update: () => ({
set: (data: any) => ({
where: async () => {
savedNumberRanges = data.numberRanges
},
}),
}),
}
const server = {
db: {
transaction: async (callback: (transaction: typeof tx) => Promise<any>) => callback(tx),
},
} as any
const occupied = new Set(["KIA-1000", "KIA-1001"])
const result = await useNextNumberRangeNumber(
server,
tenant.id,
"customerinventoryitems",
async (candidate) => !occupied.has(candidate)
)
assert.equal(result.usedNumber, "KIA-1002")
assert.equal(savedNumberRanges.customerinventoryitems.nextNumber, 1003)
})

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict"
import test from "node:test"
import { applyRowCostCentreAssignments } from "../src/modules/outgoing-document-cost-centres.service"
import { mcpToolMap } from "../src/mcp/registry"
test("changes only requested position cost centres", () => {
const rows = [
{ id: "row-1", description: "Montage", price: 100, costCentre: "old-1" },
{ id: "row-2", description: "Material", price: 50, costCentre: "old-2" },
]
assert.deepEqual(applyRowCostCentreAssignments(rows, [
{ rowId: "row-1", costcentre: "new-1" },
]), [
{ id: "row-1", description: "Montage", price: 100, costCentre: "new-1" },
rows[1],
])
})
test("rejects assignments for unknown document positions", () => {
assert.throws(
() => applyRowCostCentreAssignments([{ id: "row-1" }], [{ rowId: "missing", costcentre: null }]),
/Position missing.*nicht gefunden/,
)
})
test("registers a dedicated finalized-document cost centre tool", () => {
const tool = mcpToolMap.get("accounting.outgoing_documents.cost_centres.update")
assert.deepEqual(tool?.requiredPermissions, ["accounting.outgoing_documents.write"])
assert.deepEqual(tool?.inputSchema.required, ["id"])
assert.ok(tool?.inputSchema.anyOf)
})

View File

@@ -0,0 +1,8 @@
import assert from "node:assert/strict"
import test from "node:test"
import { resourceConfig } from "../src/utils/resource.config"
test("product details load their configured unit", () => {
assert.ok(resourceConfig.products.mtoLoad.includes("unit"))
})

View File

@@ -48,7 +48,7 @@ services:
- internal
minio:
image: minio/minio:latest
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
container_name: fedeo-minio
restart: unless-stopped
command: server /data --console-address ":9001"
@@ -66,13 +66,13 @@ services:
- internal
createbuckets:
image: minio/mc:latest
image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727
container_name: fedeo-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
/bin/sh -ec "
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
mc mb --ignore-existing local/${MINIO_BUCKET};
mc anonymous set private local/${MINIO_BUCKET};
@@ -83,7 +83,7 @@ services:
- internal
backend:
image: git.federspiel.tech/flfeders/fedeo/backend:dev
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
container_name: fedeo-backend
restart: unless-stopped
depends_on:
@@ -143,6 +143,12 @@ services:
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || '3100') + '/health', {signal: AbortSignal.timeout(4000)}).then(r => {if (!r.ok) process.exit(1)}).catch(() => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 6
start_period: 120s
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
@@ -174,15 +180,22 @@ services:
- internal
frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
image: git.federspiel.tech/flfeders/fedeo/frontend:${FEDEO_VERSION:-dev}
container_name: fedeo-frontend
restart: unless-stopped
depends_on:
- backend
backend:
condition: service_healthy
environment:
NODE_ENV: production
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health', {signal: AbortSignal.timeout(4000)}).then(r => {if (!r.ok) process.exit(1)}).catch(() => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)

View File

@@ -1,6 +1,6 @@
services:
frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
image: git.federspiel.tech/flfeders/fedeo/frontend:${FEDEO_VERSION:-dev}
restart: always
environment:
- NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend
@@ -22,7 +22,7 @@ services:
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
docs:
image: git.federspiel.tech/flfeders/fedeo/docs:dev
image: git.federspiel.tech/flfeders/fedeo/docs:${FEDEO_VERSION:-dev}
restart: always
networks:
- traefik
@@ -43,7 +43,7 @@ services:
- "traefik.http.routers.fedeo-docs-secure.priority=120"
backend:
image: git.federspiel.tech/flfeders/fedeo/backend:dev
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
restart: always
environment:
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app/docs-site
COPY docs-site/package.json docs-site/package-lock.json ./
@@ -7,9 +7,17 @@ RUN npm ci
COPY docs-site ./
COPY docs /app/docs
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
FROM node:20-alpine AS runner
FROM node:22-alpine AS runner
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /app/docs-site
ENV NODE_ENV=production

148
docs/releases.md Normal file
View File

@@ -0,0 +1,148 @@
# Versionierung und Releases
## Normaler Ablauf
Entwicklung erfolgt auf `dev`. Ein Push nach `main` ist die bewusste Freigabe zum Veröffentlichen. Der Workflow `.gitea/workflows/build-push.yaml` startet automatisch, prüft die Commit-Nachrichten und Release-Tests, baut alle sechs Images und veröffentlicht bei relevanten Änderungen einen Gitea-Release.
Nach abgeschlossener Entwicklung in einem sauberen Checkout:
```bash
git switch dev
git pull --ff-only origin dev
npm ci --ignore-scripts
npm test
npm run release:preview
git push origin dev
git switch main
git pull --ff-only origin main
git merge --no-ff dev -m "chore(release): merge dev into main"
git push origin main
git switch dev
```
Alternativ einen Pull Request von `dev` nach `main` mit einem normalen Merge übernehmen. Der Merge-Commit selbst wird bei der Versionsberechnung übersprungen; die enthaltenen `feat:`-/`fix:`-Commits bestimmen das Release. Bei Squash-Merges muss die Squash-Nachricht die größte Änderung einschließlich etwaiger Breaking Changes ausdrücken.
Es ist kein separater Versions-Commit erforderlich. `npm run release:preview` ist eine lokale, schreibfreie Vorschau; vorher mit `git fetch origin --tags` die Release-Tags aktualisieren. Ein Push, der nur Wartungsänderungen enthält, erzeugt nach dem ersten Release keine neue Version.
Ein manueller Start ist zusätzlich unter Gitea → Actions → Build and Release FEDEO → Run workflow möglich. Wähle `main` für einen Release und `dev` für einen Entwicklungsbuild. Dieser Start verwendet dieselbe Pipeline und dieselbe automatisch berechnete Version. `npm run release:ci` ist ausschließlich für die CI vorgesehen.
## Versionsregeln
Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer` und `@semantic-release/release-notes-generator` mit dem Conventional-Commits-Preset. Der Veröffentlichungsablauf ist auf Gitea 1.25.3 abgestimmt.
| Commit | Bedeutung | Release |
| --- | --- | --- |
| `fix(scope): …` | Fehlerkorrektur | Patch |
| `perf(scope): …` | Leistungsverbesserung | Patch |
| `feat(scope): …` | Neue Funktion | Minor |
| `feat(scope)!: …` / `BREAKING CHANGE: …` | Inkompatible Änderung | Major |
| `docs:`, `test:`, `refactor:`, `build:`, `ci:`, `chore:`, `style:` | Wartung ohne Breaking Change | Keiner |
| `revert: …` mit Git-Revert-Information | Zurücknahme bereits ausgelieferter Änderungen | Patch |
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Beide festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch.
## Versionen und Artefakte
Backend, Frontend, Dokumentation, Website und zentrale Dienste werden als gemeinsamer FEDEO-Stand gebaut. Die Produktversion stammt aus Git und wird beim Build eingebettet. Backend-/Frontend-Paketversionen dienen nicht als Release-Quelle.
- Stabile Images: `…/backend:3.2.0`, `…/backend:v3.2.0` und `…/backend:sha-<vollständiger Commit>`; entsprechend für alle sechs Komponenten.
- Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release.
- Entwicklung: `dev` sowie unveränderliche `dev-sha-<vollständiger Commit>-<UUID>`-Images. Alle Komponenten eines erfolgreichen Laufs erhalten denselben Snapshot-Tag. Auch eine Wiederholung desselben Commits baut einen eigenen Snapshot. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID.
- Release-Builds erhalten zunächst einen eindeutigen temporären `build-<UUID>`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben.
- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh`, `selfhost-update.py`, `SELFHOST-UPDATES.md` und `SHA256SUMS`.
Das Manifest dokumentiert die Image-Digests, den Commit und SHA-256-Prüfsummen aller enthaltenen SQL-Migrationen. Es beschreibt den ausgelieferten Migrationsstand; den tatsächlich angewendeten Stand einer Installation bestimmt deren Datenbank.
Mobile-App und Device-Agent behalten ihre eigenen bestehenden Versionsreihen. Ihre Paketversionen und die lokale iOS-Buildnummer werden im Manifest festgehalten. Die iOS-Buildnummer aus einer externen EAS-Ausführung kann davon abweichen. Ein Server-Release startet keine App-Store-Veröffentlichung und behauptet keine automatisch geprüfte Kompatibilität mit unabhängig veröffentlichten Clients. Inkompatible API-Änderungen gehören als Breaking Change mit betroffenen Client-Versionen und Upgrade-Anleitung in die Release Notes.
## Laufende Version abfragen
Im Frontend steht die Version unten in der Seitenleiste; der Tooltip enthält den Commit. Die Backend-Version ist öffentlich über diesen Endpunkt abrufbar:
```bash
curl https://app.example.com/backend/version
```
Beispiel: `{"product":"FEDEO","version":"3.2.0","revision":"<Commit-ID>"}`. Die zentrale Instanzmeldung verwendet dieselbe Backend-Version. Bei lokalen Builds ohne Build-Argumente lautet die Version `0.0.0-dev`.
## Gitea einmalig vorbereiten
Der vorhandene Actions-Runner benötigt Docker mit Buildx und Netzwerkzugriff auf npm, Basis-Images und die Registry. Alle Images werden wie der bisherige Backend-Build für `linux/amd64` erzeugt. Eine zusätzliche ARM-Variante ist ein eigener Ausbau.
Unter Repository → Settings → Actions → Variables bzw. Secrets:
- `CI_TOKEN`: Token des Registry-Benutzers `flfeders`, mindestens mit `write:package`. Die Registry-Anmeldung verwendet wie die bisherigen Build-Workflows vorrangig `vars.CI_TOKEN`; `secrets.CI_TOKEN` dient als Rückfallwert. Für eine spätere Umstellung auf ein Secret die gleichnamige Variable entfernen.
- Optional `RELEASE_TOKEN` als Secret oder Variable: Token mit `write:repository` für Release-API und Git-Tags sowie zum Erzeugen/Löschen des temporären Sperr-Branches `automation/release-lock`. Nur `main` benötigt diese Rechte. Die Reihenfolge für Git-Anmeldung und Release-API ist `secrets.RELEASE_TOKEN`, `vars.RELEASE_TOKEN`, `vars.CI_TOKEN`, `secrets.CI_TOKEN`. Ohne separates Release-Token benötigt der verwendete `CI_TOKEN` zusätzlich diese Rechte.
Auf `dev` nutzt der Checkout wie die bisherigen Workflows den normalen Job-Token zum Lesen. Entwicklungsbuilds pushen keine Git-Refs und rufen die Release-API nicht auf; der Registry-Token benötigt dafür keine Repository-Schreibrechte.
Der Release-Token-Benutzer muss die entsprechenden Repository-Rechte haben. Geschützte Tags `v*` müssen ihn zulassen; `automation/release-lock` muss durch ihn erzeugt und entfernt werden können. Die Pipeline benötigt keine Schreibrechte auf den Inhalt von `main`. Ein 403 beim Anlegen der Main-Sperre führt vor dem Image-Build zum Abbruch mit einem Hinweis auf die fehlenden Rechte.
`dev` mit der neuen Konfiguration zu pushen prüft zunächst den Entwicklungsbuild. Erst die bewusste Übernahme nach `main` erstellt den ersten stabilen Release. Release-Erstellung und Deployment haben eigene Auslöser: Server werden durch diese Pipeline nicht neu gestartet.
## Veröffentlichungsreihenfolge und Wiederaufnahme
Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Runner für Releases auf `main` atomar einen Git-Branch per leerem `--force-with-lease`. Ein zweiter Release-Runner wartet bis zu zwei Stunden, ohne die Sperre zu übernehmen. Veraltete Main-Läufe werden vor dem Build übersprungen, wenn bereits ein neuerer Main-Commit vorhanden ist; dessen Lauf enthält alle ausstehenden Änderungen.
Entwicklungsbuilds benötigen keine Git-Sperre. Parallele Läufe bauen unabhängige Snapshot-Tags. Nach dem vollständigen Build wird vor jeder Aktualisierung eines `dev`-Images geprüft, ob der Commit noch der aktuelle Stand von `origin/dev` ist. Ein erkennbar veralteter Lauf beendet die Aktualisierung. Diese Prüfung und die Registry-Aktualisierung sind nicht atomar; für einen festen Stand aller Komponenten denselben vollständigen Snapshot-Tag oder einen stabilen Release verwenden.
1. Vollständigen Commit und Tags laden, Commit-Nachrichten prüfen, Version berechnen.
2. Alle sechs Images aus diesem Checkout mit Lockfiles bauen. Der Backend-Build führt zusätzlich seine Tests aus. Vorher laufen die Release-Tests in einem eigenen Job.
3. Gitea-Entwurf erzeugen und Dateien hochladen, zuerst das Manifest mit den gebauten Digests.
4. Unveränderliche Image-Tags auf diese Digests setzen. Ein vorhandener abweichender Digest führt zum Abbruch.
5. Release veröffentlichen; Gitea erzeugt dabei den Git-Tag auf dem konkreten Commit.
6. `latest` und `main` auf die veröffentlichten Images umstellen, anschließend die Sperre entfernen.
Bei einem Release-Fehler den fehlgeschlagenen Workflow **am ursprünglichen Commit erneut ausführen**. Ein vorhandenes Manifest wird weiterverwendet; veröffentlichte Versionen werden nicht neu gebaut. Auch eine unterbrochene Aktualisierung von `latest` lässt sich so vervollständigen. Die Registry bietet keine gemeinsame Transaktion über mehrere Images: deshalb verwendet Selfhosting feste Versionen und das Manifest die exakten Digests.
Ein anderer noch offener Release-Entwurf blockiert einen neuen Release. Zuerst den ursprünglichen Lauf wiederaufnehmen; anschließend den aktuellen Main-Lauf erneut starten. Releases oder Versionstags nicht löschen, um einen fehlgeschlagenen Lauf zu überspringen.
Nach einem hart abgebrochenen Runner kann die Sperre bestehen bleiben. Zuerst in Actions sicherstellen, dass der zugehörige Lauf wirklich beendet ist. Danach ausschließlich dessen Sperre mit dem zuvor gelesenen Commit als Lease entfernen:
```bash
git ls-remote origin refs/heads/automation/release-lock
# Die ausgegebene Commit-ID anstelle von LOCK_SHA einsetzen:
git push --force-with-lease=refs/heads/automation/release-lock:LOCK_SHA origin :refs/heads/automation/release-lock
```
Keine aktive Sperre löschen. Der Lease verhindert, dass zwischenzeitlich eine andere Sperre entfernt wird. Entwicklungsbuilds erzeugen keine Sperre und können direkt erneut gestartet werden.
## Selfhosting und Updates
Für eine Installation eines bereits veröffentlichten Releases, hier beispielhaft `3.0.0`:
```bash
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/releases/download/v3.0.0/selfhost-install.sh -o /tmp/fedeo-install.sh
bash /tmp/fedeo-install.sh --simple --start
```
Dieser versionierte Installer lädt die Betriebsdateien vom zugehörigen Git-Tag und schreibt `FEDEO_VERSION=3.0.0` in die neue `.env`. Vorhandene Installationen behalten ihren bisherigen Kanal, bis `FEDEO_VERSION` bewusst geändert wird. Die Dateien im Entwicklungszweig verwenden weiterhin `dev` als Rückfallwert.
Der vollständige Host-Updater einschließlich Compose-Austausch und Backup ist in [Vollständige Selfhost-Updates](selfhost-updates.md) beschrieben. Er benötigt eine Zielversion mit gepinntem MinIO und Anwendungs-Healthchecks.
Manueller Ablauf für ältere Releases oder abweichende Installationen:
1. Release Notes und Migrationshinweise lesen. Konsistentes Datenbankbackup (z. B. `pg_dump`) sowie Datei-/Objektspeicher und Konfiguration sichern. Ein TAR des laufenden PostgreSQL-Datenverzeichnisses ist kein verlässliches Datenbankbackup.
2. Die benötigten Betriebsdateien vom gewünschten Release herunterladen; bestehende `.env` und Daten behalten. Downloads mit dessen `SHA256SUMS` überprüfen.
3. In `/opt/fedeo/.env` die feste Zielversion setzen, z. B. `FEDEO_VERSION=3.2.0`.
4. Images laden und Dienste neu erstellen:
```bash
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
curl https://app.example.com/backend/version
```
Das Backend führt standardmäßig Vorwärtsmigrationen beim Start aus. Ein Downgrade der Images setzt die Datenbank nicht zurück. Zur vorherigen Version nur bei nachgewiesener Schema-Kompatibilität wechseln oder das passende Backup in einem geplanten Wiederherstellungsablauf einspielen.
## Technische Referenzen
- [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
- [semantic-release Commit-Analyse](https://github.com/semantic-release/commit-analyzer)
- [Gitea 1.25 Actions-Einschränkungen](https://docs.gitea.com/1.25/usage/actions/comparison/)
- [Gitea 1.25 API](https://docs.gitea.com/api/1.25/)
- [Codex-Projektanweisungen mit AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md)

98
docs/selfhost-updates.md Normal file
View File

@@ -0,0 +1,98 @@
# Vollständige Selfhost-Updates
`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und einen zukünftigen Update-Dienst. Diese Implementierung installiert noch keinen Dienst und stellt noch keinen Update-Button bereit. Der Prozess muss auf dem Docker-Host außerhalb der FEDEO-Container laufen.
## Voraussetzungen und Umfang
- Linux/amd64, Python ab 3.10, Docker Compose ab 2.24, `tar` und `du`.
- Bestehendes Betriebsverzeichnis, standardmäßig `/opt/fedeo`, mit `.env` und `docker-compose.yml`; kein Git-Checkout.
- Standard-Stack mit `traefik`, `db`, `minio`, `createbuckets`, `backend`, `frontend` und `node-exporter`. PostgreSQL und MinIO verwenden die lokalen Verzeichnisse `postgres/` und `minio/`.
- Als root auf dem Server ausführen: Docker-Zugriff und Leserechte für die Daten- und Zertifikatsverzeichnisse werden benötigt. Ein entfernter Docker-Daemon wird abgewiesen.
- Die öffentliche HTTPS-Adresse muss vom Host erreichbar sein und ein gültiges Zertifikat haben.
- Eine Wartungsunterbrechung für alle Mandanten einplanen. Vor dem Backup werden alle laufenden Dienste außer PostgreSQL angehalten, auch der öffentliche Proxy und alle im Backend laufenden Hintergrundaufgaben. Eine separate Wartungsseite folgt mit der späteren Oberflächenintegration.
Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben.
Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Frontend-Healthchecks enthalten. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf.
## Bereitstellung
Neue versionierte Installer laden das Skript und diese Anleitung bereits ins Betriebsverzeichnis und berücksichtigen Python 3 bei den Basispaketen. Bestehende Installationen können `selfhost-update.py` und `SELFHOST-UPDATES.md` aus den Assets eines entsprechenden veröffentlichten Releases übernehmen. Zuerst mit dessen `SHA256SUMS` prüfen, anschließend das Skript unter `/opt/fedeo/scripts/selfhost-update.py` ablegen. Für diese erstmalige Bereitstellung ist noch ein Serverzugriff erforderlich.
Downloads erfolgen ausschließlich vom konfigurierten FEDEO-Repository über HTTPS. Die Dateiprüfsummen werden gegen `SHA256SUMS`, das Manifest gegen den Git-Tag geprüft. Die Prüfsummen sind **keine unabhängige Signatur**; das Vertrauen liegt beim Gitea-Server und dessen Release-Veröffentlichung. Falls das Repository privat ist, `FEDEO_RELEASE_TOKEN` mit Leserechten nur für den Aufruf setzen. Registry-Anmeldung bei Bedarf vorher über `docker login git.federspiel.tech` im Konto des Updaters durchführen. Der Updater speichert den Release-Token nicht in Statusdateien und gibt ihn bei Weiterleitungen an andere Hosts nicht weiter.
## Prüfen, anwenden und Status lesen
Neueste stabile Version suchen oder eine bestimmte Version prüfen:
```bash
sudo python3 /opt/fedeo/scripts/selfhost-update.py check
sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version 3.1.0
```
`3.1.0` ist ein Beispiel und muss durch einen tatsächlich veröffentlichten Zielrelease ersetzt werden. Ein anderes Betriebsverzeichnis wird mit `--directory /pfad/zur/instanz` angegeben.
Die Prüfung lädt die Betriebsdateien und erstellt einen Plan einschließlich `compose.diff` und Release Notes unter `/opt/fedeo/.updates/<Auftrag>/`. Sie verändert die laufende Installation nicht und lädt noch keine Docker-Images. Die JSON-Ausgabe nennt Zielversion, Commit, betroffene Dienste, geschätzten Backup-Platz und die Pfade der Vorschau. Unveröffentlichte Entwürfe und Vorabversionen werden ignoriert.
Beim ersten Update kennt der Updater die Herkunft der vorhandenen Compose-Datei noch nicht. Die erzeugte `compose.diff` prüfen und eigene Anpassungen in die Override-Datei übertragen. Dann den Austausch ausdrücklich akzeptieren:
```bash
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.1.0 --accept-compose-replacement
```
Für spätere Updates unveränderter, vom Updater verwalteter Compose-Dateien:
```bash
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.2.0
sudo python3 /opt/fedeo/scripts/selfhost-update.py status
```
Eine explizite Zielversion ist beim Anwenden erforderlich. `apply` prüft die Release-Dateien und die tatsächliche Installation nochmals. Bei gleichen oder älteren stabilen Zielversionen wird abgebrochen. Änderungen an `.env`, Basis-Compose oder der Override-Datei während des Downloads führen ebenfalls vor der Wartungsphase zum Abbruch.
Während des ersten Ausbaus den Befehl in einer bestehenden persistenten Terminalsitzung ausführen. Der spätere Host-Dienst übernimmt die vom Browser unabhängige Ausführung. Ein Neustart des FEDEO-Stacks beendet diesen Host-Prozess nicht; ein Neustart des Hosts unterbricht ihn.
## Ablauf und Speicherung
1. Exklusive Host-Sperre erwerben; offene Wiederherstellungsfälle blockieren neue Updates.
2. Release samt Prüfsummen laden, Manifest/Commit prüfen und die FEDEO-Images auf die exakten Digests setzen.
3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`.
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images laden, Backup-Platz erneut prüfen.
5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv.
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht.
8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen.
9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern.
Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `status.json` gespeichert. `events.jsonl` enthält den Verlauf, `commands.log` Fehlermeldungen der Werkzeuge. Das Verzeichnis hat Modus `0700`; Status- und Konfigurationsdateien werden mit `0600` angelegt. Backups und Werkzeugausgaben enthalten vertrauliche Daten und dürfen nicht ungefiltert an eine Oberfläche ausgeliefert werden. Backups werden nicht automatisch gelöscht.
## Fehler und Wiederherstellung
- **Download, Konfiguration oder Pull fehlgeschlagen:** Der laufende Stack bleibt bestehen.
- **Backup oder Stop fehlgeschlagen:** Der Updater versucht, die zuvor laufenden Container wieder zu starten. Gelingt das nicht, bleibt der Wiederherstellungsstatus gesetzt.
- **Ab Austausch der Dateien/Infrastruktur:** Keine automatische Rückkehr zu älteren Images. Bei einem Fehler werden Backend, Frontend und Proxy nach Möglichkeit angehalten. Das Datenbankschema oder das Speicherformat könnten bereits verändert sein.
- **Prozess hart beendet oder Host neu gestartet:** `status` erkennt einen zurückgelassenen laufenden Auftrag als `interrupted`. Eine vor Beginn der Wartung gespeicherte Wiederherstellungssperre bleibt erhalten. Abgeschlossene Teiloperationen werden nicht als erfolgreiches Update ausgegeben.
Zur Diagnose `status` und die geschützten Auftragsdateien lesen. Falls ein benannter `fedeo-update-<Auftrag>`-Migrationscontainer zurückgeblieben ist, dessen Zustand und Logs zuerst prüfen. Er kann unabhängig vom abgebrochenen Compose-Prozess weiterlaufen; erst nach geklärtem Zustand anhalten/entfernen.
Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster:
1. Alle schreibenden Dienste und den Proxy angehalten lassen; keinen neuen Update-Auftrag starten.
2. Prüfsummen aus `backup/complete.json` prüfen. Ursprüngliche Compose-Datei, `.env` und gegebenenfalls Override aus `backup/config/` wiederherstellen. `backup/containers.json` dokumentiert die ursprünglichen Image-IDs; insbesondere bewegliche Infrastruktur-Tags vor dem Start auf die gesicherten Image-Stände festlegen.
3. Falls Migrationen/Infrastrukturänderungen bereits begonnen haben: PostgreSQL und Dateispeicher aus dem **zusammengehörigen** Backup wiederherstellen. Der PostgreSQL-Dump ist für `pg_restore` geeignet; `globals.sql` enthält Rollen und Kennwörter und muss beim Wiederaufbau der Datenbank berücksichtigt werden. MinIO und Zertifikate bei gestoppten Diensten aus `files.tar` wiederherstellen. Die konkrete Rücksicherung bestehender Daten erfolgt bewusst manuell und ersetzt Daten.
4. Wiederhergestellten Stack mit dessen Compose-Dateien starten und Funktionen prüfen. Alternativ den Fehler der Zielversion beheben und diese erfolgreich starten.
5. Erst nach erfolgreicher Wiederherstellung die Sperre aufheben:
```bash
sudo python3 /opt/fedeo/scripts/selfhost-update.py resolve --version 3.0.0
```
`resolve` prüft die angegebene Backend-Version, Backend-Erreichbarkeit, Frontend-Erreichbarkeit, laufende Kerndienste, passende Anwendungs-Images und das Fehlen eines zurückgebliebenen Migrationscontainers. Es stellt selbst **keine Daten wieder her**. Bei alten Entwicklungsinstallationen die tatsächlich von `/backend/version` gemeldete Entwicklungsversion angeben. Ein späteres Update kann nach einer manuellen Compose-Wiederherstellung erneut `--accept-compose-replacement` erfordern.
## Entwicklung und Tests
```bash
npm test
npm run commits:check
```
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Ein vollständiger Test mit realem PostgreSQL-/MinIO-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.

64
docs/technik/mcp.md Normal file
View File

@@ -0,0 +1,64 @@
# FEDEO MCP
## Listen und Pagination
Alle datensatzbasierten MCP-Listen und Suchwerkzeuge unterstützen `limit` (ganze Zahl 1–100, Standard 25) und `offset` (ganze Zahl ab 0). Das Wiki behält Standard 50. Die statische Liste der Beleg-Steuertypen benötigt keine Pagination.
Die Antwort behält `rows` und ergänzt:
```json
{"pagination":{"limit":25,"offset":0,"hasMore":true,"nextOffset":25}}
```
Für die nächste Seite dieselben Filter und `limit` mit `offset = pagination.nextOffset` verwenden. Bei `hasMore = false` ist `nextOffset = null`. Die Sortierung enthält eine eindeutige ID als Tie-Breaker. Gleichzeitiges Anlegen, Archivieren oder Ändern sortierrelevanter Felder kann wie bei jeder Offset-Pagination Seiten verschieben; die Abfragen bilden keinen gemeinsamen Datenbanksnapshot.
Auch `accounting.incoming_invoices.files.list` ist paginiert. Archivierte Einträge bleiben entsprechend der bisherigen MCP-Regel ausgeblendet.
## Ausgangsbeleg-Schemas
`tools/list` beschreibt die Unterfelder von `rows`, `address` und `report` für Beleg-Erstellung und -Bearbeitung sowie Positionen und Report beim Finalisieren. Positionen verwenden `costCentre`, der Beleg selbst `costcentre`. `unit` ist die numerische Einheiten-ID. Preise werden numerisch übergeben. Reportwerte liefert der Aufrufer; MCP berechnet sie nicht. Zusätzliche modulspezifische Objektfelder bleiben zulässig.
## Stammdaten und Kostenstellen
Die folgenden Namensräume besitzen jeweils `create`, `update` und `archive`:
| Namensraum nach `masterdata.` | Anlegen: Pflichtfelder | Automatischer Nummernkreis |
| --- | --- | --- |
| `customers` | `name` | `customerNumber` |
| `vendors` | `name` | `vendorNumber` |
| `contacts` | `fullName` oder `firstName`/`lastName` | – |
| `products` | `name`, `unit` | – |
| `services` | `name` | – |
| `cost_centres` | `name` | `number` |
| `branches` | `name` | `number` |
| `teams` | `name` | – |
| `vehicles` | `name` | – |
| `inventory_items` | `name` | `articleNumber` |
Nummernfelder sind beim Anlegen optional; ohne Angabe wird der bestehende Mandanten-Nummernkreis verwendet. Dieser muss in FEDEO eingerichtet sein. Bereits belegte Nummern werden übersprungen; explizite doppelte Nummern werden abgewiesen, auch wenn der vorhandene Eintrag archiviert ist.
`update` verlangt `id` und mindestens ein bearbeitbares Feld. Kostenstellen verwenden UUIDs, die übrigen Bereiche numerische IDs. Nur angegebene Felder werden geändert. Nullable Felder lassen sich mit `null` leeren, Arrays werden vollständig ersetzt. Die Unterfelder von `infoData` werden beim Update zusammengeführt. Systemfelder wie Mandant, ID beim Anlegen, Archivstatus und Änderungsautor sind nicht frei schreibbar.
Beispiel zum Anlegen einer Kostenstelle:
```json
{"name":"masterdata.cost_centres.create","arguments":{"name":"Werkstatt","description":"Kosten der Werkstatt","branch":3}}
```
Über `cost_centres.update` lassen sich `parentCostcentre`, `project`, `branch`, `vehicle`, `inventoryitem` und `profiles` pflegen. Eltern müssen zum aktiven Mandanten gehören und dürfen nicht archiviert sein; Selbstbezüge und neue Hierarchie-Zyklen werden abgewiesen. Die HTTP-Ressourcenroute und MCP verwenden denselben Hierarchieprüfer.
Fremdschlüssel und bekannte Verknüpfungen in JSON-Feldern werden auf Mandantenzugehörigkeit und Verfügbarkeit geprüft. Globale Mengeneinheiten bleiben lesbar; es werden keine Schreibwerkzeuge für diese mandantenübergreifenden Daten registriert.
Artikel- und Leistungsänderungen stoßen die bestehende Leistungspreisberechnung an. Bei zusammengesetzten Leistungen stammen Preise aus der Zusammensetzung; für eine manuelle Preisvorgabe die Leistung mit `priceUpdateLocked: true` sperren. Manuelle `sellingPrice`-Werte werden mit `sellingPriceComposed.total` synchronisiert.
Schreibvorgänge einschließlich Nummernvergabe und Preisberechnung laufen in einer Transaktion. Historieneinträge werden über den vorhandenen History-Service erstellt; für Niederlassungen und Teams unterstützt dieser derzeit keine entitätsbezogene Historie. Die Änderungsfelder enthalten auch dort den ausführenden Benutzer und Zeitpunkt. Archivieren liefert `{ "id": ..., "success": true, "action": "archived" }`; der archivierte Datensatz wird entsprechend der MCP-Regel nicht zurückgegeben.
Zusätzlich verfügbar: `masterdata.customers.search` sowie `get` für Kontakte, Niederlassungen, Teams und Fahrzeuge. Die vorhandene Kundensuche unter `organisation.customers.search` bleibt erhalten.
### Berechtigungen
Schreibwerkzeuge benötigen `masterdata.<Namensraum>.write`, lesende Werkzeuge `.read`. Die neuen Schreibrechte sind in den Bootstrap-Administratorrechten enthalten. Bestehende Nicht-Administratorrollen erhalten nicht automatisch Schreibzugriff; ihnen müssen die gewünschten Rechte ausdrücklich zugewiesen werden. Globale Administratoren verwenden die bestehende Admin-Ausnahme.
### Verifikation
Die MCP-Tests prüfen unter anderem seitenweisen Abruf, Tenant-Filter in den erzeugten SQL-Abfragen, Create/Update/Archive, unbekannte Systemfelder, Fremdmandanten-Verknüpfungen, Kostenstellen-Zyklen, Nummernkollisionen, Transaktions-Rollback und die Preisneuberechnung. Die Datenbanktests verwenden isolierte Fixtures bzw. den Drizzle-Proxy; sie schreiben keine produktiven Daten.

8
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.nuxt
.output
.git
.env
.env.*
!.env.example
*.log

View File

@@ -1,18 +1,26 @@
# --- Stage 1: Build ---
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /usr/src/nuxt-app
# Nur Files kopieren, die für die Installation nötig sind (besseres Caching)
COPY package*.json ./
RUN npm install
RUN npm ci
# Restlichen Code kopieren und bauen
COPY . .
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
# --- Stage 2: Runtime ---
FROM node:20-alpine AS runner
FROM node:22-alpine AS runner
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /usr/src/nuxt-app

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,282 @@
<script setup lang="ts">
import type { InstanceAgent } from "~/composables/useAdmin"
import FileScanModal from "~/components/FileScanModal.vue"
import bundle from "~/assets/device-agent/bundle.json"
import { agentBackendUrl, agentSetupReady, ix1600ScanDefaults, renderAgentInstaller } from "~/utils/agentInstaller.mjs"
const props = defineProps<{ initialAgent?: InstanceAgent | null }>()
const emit = defineEmits<{ updated: [id: string]; close: [] }>()
const admin = useAdmin()
const toast = useToast()
const modal = useModal()
const runtimeConfig = useRuntimeConfig()
const steps = ["Agent", "Installation", "Verbindung", "Scanner"]
const step = ref(0)
const agent = ref<InstanceAgent | null>(props.initialAgent || null)
const name = ref(props.initialAgent?.name || "Linux Büro")
const description = ref(props.initialAgent?.description || "")
const backendUrl = ref("")
const token = ref("")
const autostart = ref(true)
const rotateConfirmed = ref(false)
const busy = ref(false)
const downloaded = ref(false)
const saved = ref(false)
const selectedScanner = ref(props.initialAgent?.preferredScannerName || "")
const profile = ref(props.initialAgent && !/ix1600/i.test(props.initialAgent.preferredScannerName || "") ? "existing" : "ix1600")
const now = ref(Date.now())
const checkingSince = ref(0)
const checking = ref(false)
const connectionError = ref("")
const configurationError = ref("")
let disposed = false
let pollTimer: ReturnType<typeof setTimeout> | undefined
let clockTimer: ReturnType<typeof setInterval> | undefined
const filename = computed(() => 'fedeo-agent-' + (agent.value?.id || 'setup') + '.sh')
const installCommand = computed(() => 'bash ' + filename.value)
const tokenReady = computed(() => /^fedeo_agent_[a-f0-9]{64}$/.test(token.value.trim()))
const online = computed(() => agentSetupReady(agent.value, checkingSince.value, now.value))
const scannerOptions = computed(() => (agent.value?.scannerNames || []).map(value => ({ label: value, value })))
const readyToSave = computed(() => online.value && agent.value?.capabilities?.scan && scannerOptions.value.some(item => item.value === selectedScanner.value))
const endpointPreview = computed(() => {
try { return agentBackendUrl(backendUrl.value) + '/instance-agent/heartbeat' } catch { return '' }
})
// Tokens stay in component memory and in the user's downloaded file only.
watch([backendUrl, token, autostart], () => { downloaded.value = false; configurationError.value = '' })
const fail = (error: any, title: string) => toast.add({ title, description: error?.data?.error || error?.message || 'Unbekannter Fehler', color: 'error' })
const prepareAgent = async () => {
if (busy.value || (!agent.value && !name.value.trim())) return
busy.value = true
try {
if (!agent.value) {
const result = await admin.createInstanceAgent({ name: name.value.trim(), description: description.value.trim() || null })
agent.value = result.agent
token.value = result.token
emit('updated', result.agent.id)
} else if (!agent.value.active) {
await admin.updateInstanceAgent(agent.value.id, { active: true })
agent.value = { ...agent.value, active: true }
emit('updated', agent.value.id)
}
step.value = 1
} catch (error) { fail(error, 'Agent konnte nicht vorbereitet werden') }
finally { busy.value = false }
}
const rotateToken = async () => {
if (!agent.value || !rotateConfirmed.value || busy.value) return
busy.value = true
try {
token.value = (await admin.rotateInstanceAgentToken(agent.value.id)).token
rotateConfirmed.value = false
emit('updated', agent.value.id)
} catch (error) { fail(error, 'Token konnte nicht erneuert werden') }
finally { busy.value = false }
}
const downloadInstaller = () => {
if (!agent.value) return
try {
const script = renderAgentInstaller(bundle, { agentId: agent.value.id, token: token.value.trim(), backendUrl: backendUrl.value, autostart: autostart.value })
const url = URL.createObjectURL(new Blob([script], { type: 'text/x-shellscript;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = filename.value
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
downloaded.value = true
} catch (error: any) { configurationError.value = error.message }
}
const copyCommand = async () => {
try {
await navigator.clipboard.writeText(installCommand.value)
toast.add({ title: 'Installationsbefehl kopiert', color: 'success' })
} catch { toast.add({ title: 'Bitte den angezeigten Befehl manuell kopieren', color: 'info' }) }
}
const stopPolling = () => { if (pollTimer) clearTimeout(pollTimer); pollTimer = undefined }
const checkConnection = async () => {
if (!agent.value || checking.value || disposed) return
stopPolling()
checking.value = true
connectionError.value = ''
try {
const result = await admin.getInstanceAgents(true)
if (disposed) return
const current = result.agents.find(item => item.id === agent.value?.id)
if (!current) throw new Error('Dieser Agent wurde nicht gefunden. Bitte die Verwaltung aktualisieren.')
agent.value = current
now.value = Date.now()
if (!current.scannerNames?.includes(selectedScanner.value)) {
selectedScanner.value = current.scannerNames?.find(scanner => /ix1600/i.test(scanner)) || current.scannerNames?.[0] || ''
}
} catch (error: any) { connectionError.value = error?.data?.error || error?.message || 'Verbindung zu FEDEO fehlgeschlagen' }
finally {
checking.value = false
if (!disposed && step.value >= 2 && !saved.value && !connectionError.value) pollTimer = setTimeout(checkConnection, 5000)
}
}
const startConnectionCheck = () => { step.value = 2; checkingSince.value = Date.now() - 1000; checkConnection() }
const backToInstallation = () => { step.value = 1; stopPolling() }
const finishSetup = async () => {
if (!agent.value || !readyToSave.value || busy.value) return
busy.value = true
try {
const scanDefaults = profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : {
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false, ...agent.value.scanDefaults,
}
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults })
saved.value = true
token.value = ''
stopPolling()
emit('updated', agent.value.id)
} catch (error) { fail(error, 'Scanner konnte nicht gespeichert werden') }
finally { busy.value = false }
}
const openTestScan = () => modal.open(FileScanModal, { scanData: { agentId: agent.value?.id, folder: null, type: null } })
onMounted(() => {
backendUrl.value = agentBackendUrl(runtimeConfig.public.apiBase, window.location.origin)
clockTimer = setInterval(() => { now.value = Date.now() }, 1000)
})
onBeforeUnmount(() => {
disposed = true
token.value = ''
stopPolling()
if (clockTimer) clearInterval(clockTimer)
})
</script>
<template>
<UCard class="mb-6" :ui="{ root: 'rounded-lg border-primary/40' }">
<template #header>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-highlighted">{{ agent ? agent.name + ' einrichten' : 'Scanner-Agent einrichten' }}</h2>
<p class="mt-1 text-sm text-muted">Schritt für Schritt auf deinem Linux-Rechner installieren und mit FEDEO verbinden.</p>
</div>
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" aria-label="Einrichtung schließen" :disabled="busy" @click="emit('close')" />
</div>
</template>
<ol class="mb-7 grid grid-cols-2 gap-3 sm:grid-cols-4" aria-label="Einrichtungsschritte">
<li v-for="(label, index) in steps" :key="label" :aria-current="step === index ? 'step' : undefined"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
:class="step === index ? 'bg-primary/10 font-semibold text-primary' : 'bg-muted/40 text-muted'">
<span class="flex size-6 shrink-0 items-center justify-center rounded-full border border-current text-xs">{{ saved || index < step ? '✓' : index + 1 }}</span>
{{ label }}
</li>
</ol>
<div v-if="step === 0" class="max-w-2xl space-y-5">
<p class="text-sm text-muted">Der Agent läuft auf dem Rechner, an dem dein Scanner per USB angeschlossen ist. Der Download unterstützt Linux Mint, Ubuntu und Debian.</p>
<template v-if="!agent">
<UFormField label="Name des Rechners" required>
<UInput v-model="name" placeholder="Linux Büro" class="w-full" :disabled="busy" @keydown.enter.prevent="prepareAgent" />
</UFormField>
<UFormField label="Beschreibung">
<UInput v-model="description" placeholder="Standort oder Arbeitsplatz" class="w-full" :disabled="busy" />
</UFormField>
</template>
<UAlert v-else color="info" variant="soft" title="Vorhandenen Agenten einrichten"
:description="agent.active ? 'Du kannst die Installation auf diesem Rechner erneuern.' : 'Beim Fortsetzen wird dieser Agent wieder aktiviert.'" />
<UButton :loading="busy" :disabled="!agent && !name.trim()" @click="prepareAgent">{{ agent ? 'Weiter zur Installation' : 'Agent anlegen und weiter' }}</UButton>
</div>
<div v-else-if="step === 1" class="grid gap-6 lg:grid-cols-2">
<div class="space-y-5">
<UFormField label="FEDEO-Serveradresse" description="Vom Scanner-Rechner erreichbare Adresse. Ein vorhandener Pfad wie /backend muss erhalten bleiben.">
<UInput v-model="backendUrl" class="w-full" placeholder="https://fedeo.example.com/backend" />
</UFormField>
<p v-if="endpointPreview" class="break-all text-xs text-muted">Verbindungsziel: {{ endpointPreview }}</p>
<UAlert v-if="tokenReady" color="success" variant="soft" title="Zugangstoken bereit" description="Der Token wird in deine Installationsdatei übernommen. Er bleibt auf dieser Seite nur bis zum Schließen verfügbar." />
<template v-else>
<UFormField label="Vorhandener Agent-Token" description="Falls du ihn bereits gesichert hast, hier einfügen.">
<UInput v-model="token" type="password" autocomplete="off" class="w-full" placeholder="fedeo_agent_…" />
</UFormField>
<div class="space-y-3 rounded-lg border border-default p-4">
<p class="text-sm text-muted">Token nicht mehr vorhanden? Ein neuer Token ersetzt den bisherigen Zugang.</p>
<UCheckbox v-model="rotateConfirmed" label="Die bisherige Installation darf ihren Zugang verlieren." />
<UButton color="neutral" variant="outline" :disabled="!rotateConfirmed" :loading="busy" @click="rotateToken">Neuen Token erzeugen</UButton>
</div>
</template>
<UCheckbox v-model="autostart" label="Agent bei meiner Linux-Anmeldung automatisch starten" />
<p class="text-xs text-muted">Ohne Autostart läuft der Agent im Terminal. Der Rechner muss zum Scannen eingeschaltet und angemeldet sein.</p>
</div>
<div class="space-y-4 rounded-lg bg-muted/40 p-5">
<h3 class="font-semibold text-highlighted">Installation herunterladen</h3>
<p class="text-sm text-muted">Die Datei enthält die Agent-Software und deine Konfiguration. Sie prüft Node.js ab Version 20, SANE und Python/Pillow und installiert fehlende Systempakete mit sudo.</p>
<p class="text-sm text-muted">Die Installation erfolgt in deinem Benutzerverzeichnis. Starte die Datei auf dem Rechner mit dem Scanner.</p>
<UAlert v-if="configurationError" color="error" variant="soft" :title="configurationError" />
<UButton icon="i-heroicons-arrow-down-tray" :disabled="!tokenReady || busy" @click="downloadInstaller">Linux-Installation herunterladen</UButton>
<p class="text-xs text-muted">Die Datei enthält Zugangsdaten. Bewahre sie geschützt auf oder lösche sie nach erfolgreicher Einrichtung.</p>
<UButton v-if="downloaded" icon="i-heroicons-arrow-right" @click="startConnectionCheck">Weiter: Starten und Verbindung prüfen</UButton>
</div>
</div>
<div v-else-if="step === 2" class="grid gap-6 lg:grid-cols-2">
<div class="space-y-4">
<h3 class="font-semibold text-highlighted">Auf dem Scanner-Rechner starten</h3>
<ol class="list-decimal space-y-3 pl-5 text-sm text-muted">
<li>Scanner einschalten und per USB anschließen.</li>
<li>Im Download-Ordner ein Terminal öffnen und diesen Befehl ausführen:</li>
</ol>
<div class="rounded-lg bg-muted p-3">
<code class="block break-all text-sm text-highlighted">{{ installCommand }}</code>
<UButton class="mt-3" size="sm" color="neutral" variant="outline" icon="i-heroicons-clipboard-document" @click="copyCommand">Befehl kopieren</UButton>
</div>
<p class="text-sm text-muted">Ohne sudo starten. Falls Systempakete fehlen, fragt die Installation nach deinem Passwort. Lass diese Seite geöffnet; der Status wird alle fünf Sekunden geprüft.</p>
<UButton color="neutral" variant="ghost" @click="backToInstallation">Zurück zur Konfiguration</UButton>
</div>
<div class="space-y-4" aria-live="polite">
<UAlert :color="online ? 'success' : 'info'" variant="soft" :title="online ? 'Agent mit FEDEO verbunden' : 'Warte auf Verbindung vom Agenten'"
:description="online ? 'Rechner: ' + (agent?.lastDebugInfo?.hostname || agent?.name) : 'Sobald die Installation gestartet ist und sich dieser Agent meldet, erscheint er hier.'" />
<UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" />
<p class="text-sm text-muted">Erkannte Scanner: {{ agent?.scannerNames?.length || 0 }}</p>
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Jetzt prüfen</UButton>
<UButton :disabled="!online" @click="step = 3">Weiter: Scanner auswählen</UButton>
<details class="text-sm text-muted">
<summary class="cursor-pointer font-medium">Verbindung kommt nicht zustande?</summary>
<p class="mt-3">Die Installationsausgabe auf Fehler prüfen. Die FEDEO-Adresse muss von diesem Rechner erreichbar sein und den Backend-Pfad enthalten. „localhost“ verweist immer auf den Scanner-Rechner. Bei einem erneuerten Token muss die neue Installationsdatei ausgeführt werden.</p>
</details>
</div>
</div>
<div v-else-if="!saved" class="max-w-2xl space-y-5">
<UAlert v-if="!online" color="warning" variant="soft" title="Agent ist derzeit nicht verbunden" description="Agent starten und die Verbindung erneut prüfen." />
<UAlert v-else-if="!agent?.capabilities?.scan || !scannerOptions.length" color="warning" variant="soft" title="Noch kein Scanner verfügbar"
description="USB-Verbindung und Stromversorgung prüfen. scanimage -L muss den Scanner für denselben Linux-Benutzer anzeigen." />
<UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" />
<UFormField label="Scanner" required>
<USelectMenu v-model="selectedScanner" :items="scannerOptions" value-key="value" label-key="label" class="w-full" placeholder="Erkannten Scanner auswählen" />
</UFormField>
<UFormField label="Scan-Profil">
<USelectMenu v-model="profile" :items="[
{ label: 'ScanSnap iX1600: A4, Duplex, PDF, 300 dpi', value: 'ix1600' },
{ label: 'Bisherige Einstellungen / allgemeiner Scanner', value: 'existing' }
]" value-key="value" label-key="label" class="w-full" />
</UFormField>
<p class="text-sm text-muted">Das iX1600-Profil speichert den gesamten Stapel mit Vorder- und Rückseiten in einem PDF. Weitere Einstellungen kannst du anschließend in der Scannerverwaltung ändern.</p>
<div class="flex flex-wrap gap-3">
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Scanner erneut suchen</UButton>
<UButton :disabled="!readyToSave" :loading="busy" @click="finishSetup">Einrichtung abschließen</UButton>
</div>
</div>
<div v-else class="space-y-4">
<UAlert color="success" variant="soft" title="Dein Scanner ist eingerichtet" description="Du kannst jetzt unter Dateien → Scan scannen. Das PDF wird im geöffneten Ordner gespeichert." />
<p class="text-sm text-muted">Für einen Test zwei Blätter einlegen. Der Testscan wird im Hauptordner des aktuell ausgewählten Mandanten gespeichert; der Scan startet erst nach deinem Klick im Scan-Dialog.</p>
<div class="flex flex-wrap gap-3">
<UButton icon="i-heroicons-document-magnifying-glass" @click="openTestScan">Testscan öffnen</UButton>
<UButton color="neutral" variant="outline" @click="emit('close')">Zur Scannerverwaltung</UButton>
</div>
</div>
</UCard>
</template>

View File

@@ -22,6 +22,8 @@ const agents = ref([])
const selectedAgentId = ref(null)
const currentJob = ref(null)
const statusMessage = ref("")
let disposed = false
onBeforeUnmount(() => { disposed = true })
const scanForm = reactive({
filename: `scan-${new Date().toISOString().slice(0, 10)}.pdf`,
@@ -30,10 +32,27 @@ const scanForm = reactive({
resolution: 300,
mode: "Color",
source: "ADF Duplex",
postprocess: true,
postprocessProfile: "receipt"
paperSize: "A4",
postprocess: false,
postprocessProfile: "document"
})
const isFeederScan = computed(() => /adf|document feeder/i.test(scanForm.source || ""))
const formatOptions = computed(() => isFeederScan.value
? [{ label: "PDF (alle Seiten)", value: "pdf" }]
: [{ label: "PDF", value: "pdf" }, { label: "PNG", value: "png" }, { label: "TIFF", value: "tiff" }])
watch(isFeederScan, (feeder) => { if (feeder) scanForm.format = "pdf" })
watch(() => scanForm.format, (format) => {
scanForm.filename = `${scanForm.filename.replace(/\.(pdf|png|tiff?)$/i, "")}.${format}`
})
const applyIx1600Preset = () => {
Object.assign(scanForm, {
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
paperSize: "A4", postprocess: false, postprocessProfile: "document"
})
}
const activeAgents = computed(() =>
agents.value.filter((agent) => agent.active && agent.capabilities?.scan)
)
@@ -62,8 +81,10 @@ const applyAgentDefaults = (agent) => {
scanForm.resolution = Number(agent.scanDefaults?.resolution || 300)
scanForm.mode = agent.scanDefaults?.mode || "Color"
scanForm.source = agent.scanDefaults?.source || "ADF Duplex"
scanForm.postprocess = agent.scanDefaults?.postprocess !== false
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "receipt"
scanForm.paperSize = agent.scanDefaults?.paperSize || "A4"
scanForm.postprocess = Boolean(agent.scanDefaults?.postprocess)
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
if (isFeederScan.value) scanForm.format = "pdf"
if (!scanForm.filename || scanForm.filename.startsWith("scan-")) {
scanForm.filename = `scan-${new Date().toISOString().slice(0, 10)}.${scanForm.format || "pdf"}`
@@ -80,7 +101,9 @@ const loadAgents = async () => {
agents.value = response?.agents || []
const firstOnlineAgent = activeAgents.value.find(isOnline)
selectedAgentId.value = firstOnlineAgent?.id || activeAgents.value[0]?.id || null
selectedAgentId.value = props.scanData.agentId
? activeAgents.value.find(agent => agent.id === props.scanData.agentId)?.id || null
: firstOnlineAgent?.id || activeAgents.value[0]?.id || null
applyAgentDefaults(selectedAgent.value)
} catch (err) {
toast.add({
@@ -94,10 +117,19 @@ const loadAgents = async () => {
}
const waitForJob = async (jobId) => {
for (let i = 0; i < 90; i++) {
while (!disposed) {
await new Promise((resolve) => setTimeout(resolve, 2000))
if (disposed) return null
const response = await $api(`/api/scan-jobs/${jobId}`)
let response
try {
response = await $api(`/api/scan-jobs/${jobId}`, { timeout: 15000, retry: 0 })
} catch (err) {
if ([401, 403, 404].includes(err?.statusCode || err?.response?.status)) throw err
statusMessage.value = "Verbindung unterbrochen. Der Scan läuft auf dem Rechner weiter; Status wird erneut abgefragt..."
continue
}
if (disposed) return null
const job = response?.job
currentJob.value = job
@@ -106,16 +138,15 @@ const waitForJob = async (jobId) => {
throw new Error(job.agentMessage || "Scan-Auftrag wurde abgebrochen.")
}
statusMessage.value = job?.status === "running"
statusMessage.value = job?.agentMessage || (job?.status === "running"
? "Scanner arbeitet..."
: "Warte auf den Agenten..."
: "Warte auf den Agenten...")
}
throw new Error("Der Scan-Auftrag läuft länger als erwartet.")
return null
}
const startScan = async () => {
if (!selectedAgentId.value) return
if (!selectedAgentId.value || scanInProgress.value) return
scanInProgress.value = true
statusMessage.value = "Scan-Auftrag wird erstellt..."
@@ -133,6 +164,7 @@ const startScan = async () => {
resolution: Number(scanForm.resolution || 300),
mode: scanForm.mode || "Color",
source: scanForm.source || null,
paperSize: scanForm.paperSize,
postprocess: scanForm.postprocess,
postprocessProfile: scanForm.postprocessProfile
},
@@ -150,17 +182,19 @@ const startScan = async () => {
statusMessage.value = "Warte auf den Agenten..."
await waitForJob(response.job.id)
const finishedJob = await waitForJob(response.job.id)
if (!finishedJob || disposed) return
toast.add({
title: "Scan gespeichert",
description: props.scanData.folder ? "Die Datei wurde im aktuellen Ordner abgelegt." : "Die Datei wurde in Dateien abgelegt.",
description: finishedJob.agentMessage || (props.scanData.folder ? "Die Datei wurde im aktuellen Ordner abgelegt." : "Die Datei wurde in Dateien abgelegt."),
color: "success"
})
emit("scanFinished")
modal.close()
} catch (err) {
if (disposed) return
toast.add({
title: "Scan fehlgeschlagen",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
@@ -246,6 +280,10 @@ loadAgents()
<UInput v-model="scanForm.filename" :disabled="scanInProgress" />
</UFormField>
<UButton color="neutral" variant="soft" :disabled="scanInProgress" @click="applyIx1600Preset">
ScanSnap iX1600: A4, Duplex, PDF
</UButton>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Quelle">
<UInput v-model="scanForm.source" placeholder="ADF Duplex" :disabled="scanInProgress" />
@@ -255,6 +293,17 @@ loadAgents()
</UFormField>
</div>
<UFormField label="Papierformat">
<USelectMenu v-model="scanForm.paperSize" :items="[
{ label: 'A4', value: 'A4' }, { label: 'Letter', value: 'Letter' },
{ label: 'Scanner-Vorgabe', value: 'auto' }
]" value-key="value" label-key="label" :disabled="scanInProgress" />
</UFormField>
<p v-if="isFeederScan" class="text-xs text-muted">
Der gesamte Stapel wird als ein PDF gespeichert. Bei Duplex bleiben Vorder- und Rückseiten einschließlich Leerseiten erhalten.
</p>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Modus">
<UInput v-model="scanForm.mode" placeholder="Color" :disabled="scanInProgress" />
@@ -262,11 +311,7 @@ loadAgents()
<UFormField label="Format">
<USelectMenu
v-model="scanForm.format"
:items="[
{ label: 'PDF', value: 'pdf' },
{ label: 'PNG', value: 'png' },
{ label: 'TIFF', value: 'tiff' }
]"
:items="formatOptions"
value-key="value"
label-key="label"
:disabled="scanInProgress"
@@ -307,7 +352,9 @@ loadAgents()
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" :disabled="scanInProgress" @click="modal.close()">Abbrechen</UButton>
<UButton color="neutral" variant="ghost" @click="modal.close()">
{{ scanInProgress ? 'Im Hintergrund weiterlaufen lassen' : 'Schließen' }}
</UButton>
<UButton
icon="i-heroicons-document-magnifying-glass"
:loading="scanInProgress"

View File

@@ -255,8 +255,10 @@ export const useAdmin = () => {
return await $api("/api/admin/system-status")
}
const getInstanceAgents = async (): Promise<{ agents: InstanceAgent[] }> => {
const response = await $api("/api/instance-agents") as any
const getInstanceAgents = async (quiet = false): Promise<{ agents: InstanceAgent[] }> => {
const response = await $api("/api/instance-agents", quiet ? {
retry: 0, timeout: 15000, onRequestError: () => {}, onResponseError: () => {},
} : {}) as any
return { agents: response?.agents || [] }
}
@@ -274,12 +276,17 @@ export const useAdmin = () => {
})
}
const rotateInstanceAgentToken = async (id: string): Promise<{ token: string }> => {
return await $api(`/api/instance-agents/${id}/token`, { method: "POST" })
}
return {
getOverview,
getSystemStatus,
getInstanceAgents,
createInstanceAgent,
updateInstanceAgent,
rotateInstanceAgentToken,
createUser,
createUserForProfile,
updateUser,

View File

@@ -12,6 +12,7 @@ const colorMode = useColorMode()
const {isHelpSlideoverOpen} = useDashboard()
const router = useRouter()
const route = useRoute()
const buildInfo = useRuntimeConfig().public
const auth = useAuthStore()
const labelPrinter = useLabelPrinterStore()
@@ -298,6 +299,9 @@ onMounted(() => {
</UButton>
</div>
<p v-if="!collapsed" class="px-2.5 text-xs text-gray-500 dark:text-gray-400" :title="buildInfo.fedeoRevision">
FEDEO {{ buildInfo.fedeoVersion }}
</p>
<USeparator class="sticky bottom-0 w-full"/>
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;" class="w-full"/>
</div>

View File

@@ -81,6 +81,8 @@ export default defineNuxtConfig({
public: {
apiBase: '',
pdfLicense: '',
fedeoVersion: process.env.FEDEO_VERSION || '0.0.0-dev',
fedeoRevision: process.env.FEDEO_REVISION || 'unknown',
}
},

View File

@@ -3741,6 +3741,18 @@
}
}
},
"node_modules/@nuxt/cli/node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@nuxt/devalue": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz",

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { InstanceAgent } from "~/composables/useAdmin"
import AgentSetupWizard from "~/components/AgentSetupWizard.vue"
const auth = useAuthStore()
const router = useRouter()
@@ -8,15 +9,16 @@ const admin = useAdmin()
const loading = ref(true)
const saving = ref(false)
const creating = ref(false)
const agents = ref<InstanceAgent[]>([])
const selectedAgentId = ref<string | null>(null)
const generatedToken = ref("")
const newAgent = reactive({
name: "",
description: "",
})
const setupOpen = ref(false)
const setupTarget = ref<InstanceAgent | null>(null)
const setupKey = ref(0)
const openSetup = (agent: InstanceAgent | null = null) => {
setupTarget.value = agent
setupKey.value++
setupOpen.value = true
}
const editState = reactive({
name: "",
@@ -27,6 +29,7 @@ const editState = reactive({
resolution: 300,
mode: "Color",
source: "",
paperSize: "A4",
postprocess: false,
postprocessProfile: "document",
})
@@ -64,10 +67,18 @@ const applyAgentToForm = (agent: InstanceAgent | null) => {
editState.resolution = Number(agent.scanDefaults?.resolution || 300)
editState.mode = agent.scanDefaults?.mode || "Color"
editState.source = agent.scanDefaults?.source || ""
editState.paperSize = agent.scanDefaults?.paperSize || "A4"
editState.postprocess = Boolean(agent.scanDefaults?.postprocess)
editState.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
}
const applyIx1600Preset = () => {
Object.assign(editState, {
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
paperSize: "A4", postprocess: false, postprocessProfile: "document"
})
}
watch(selectedAgent, (agent) => applyAgentToForm(agent), { immediate: true })
const loadAgents = async () => {
@@ -94,42 +105,17 @@ const loadAgents = async () => {
}
}
const createAgent = async () => {
if (!newAgent.name.trim()) return
creating.value = true
generatedToken.value = ""
try {
const response = await admin.createInstanceAgent({
name: newAgent.name.trim(),
description: newAgent.description.trim() || null,
})
generatedToken.value = response.token
newAgent.name = ""
newAgent.description = ""
selectedAgentId.value = response.agent.id
const setupUpdated = async (id: string) => {
selectedAgentId.value = id
await loadAgents()
toast.add({
title: "Agent angelegt",
description: "Der Token wird nur jetzt vollständig angezeigt.",
color: "success",
})
} catch (err: any) {
toast.add({
title: "Agent konnte nicht angelegt werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "error",
})
} finally {
creating.value = false
}
}
const saveAgent = async () => {
if (!selectedAgent.value) return
if (/adf|document feeder/i.test(editState.source) && editState.format !== "pdf") {
toast.add({ title: "Für Einzug- und Duplex-Scans bitte PDF wählen", color: "error" })
return
}
saving.value = true
@@ -144,6 +130,7 @@ const saveAgent = async () => {
resolution: Number(editState.resolution || 300),
mode: editState.mode,
source: editState.source || null,
paperSize: editState.paperSize,
postprocess: editState.postprocess,
postprocessProfile: editState.postprocessProfile,
},
@@ -178,6 +165,7 @@ onMounted(async () => {
<template>
<UDashboardNavbar title="Administration: Scanner">
<template #right>
<UButton v-if="!setupOpen" icon="i-heroicons-plus" @click="openSetup()">Agent einrichten</UButton>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
@@ -191,47 +179,17 @@ onMounted(async () => {
</UDashboardNavbar>
<UDashboardPanelContent>
<div class="grid min-h-0 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<AgentSetupWizard v-if="setupOpen" :key="setupKey" :initial-agent="setupTarget" @updated="setupUpdated" @close="setupOpen = false" />
<div v-show="!setupOpen" class="grid min-h-0 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div class="space-y-4">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-plus-circle" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">Agent anlegen</h2>
<div class="space-y-4">
<h2 class="text-base font-semibold text-highlighted">Neuen Scanner-Rechner verbinden</h2>
<p class="text-sm text-muted">Der Assistent erstellt die passende Linux-Installation und begleitet dich bis zum ersten Scan.</p>
<UButton icon="i-heroicons-sparkles" @click="openSetup()">Einrichtung starten</UButton>
</div>
</template>
<form class="space-y-3" @submit.prevent="createAgent">
<UFormField label="Name">
<UInput v-model="newAgent.name" placeholder="MacBook Büro" />
</UFormField>
<UFormField label="Beschreibung">
<UTextarea v-model="newAgent.description" :rows="2" placeholder="Arbeitsplatz oder Standort" />
</UFormField>
<UButton
type="submit"
icon="i-heroicons-plus"
:loading="creating"
:disabled="!newAgent.name.trim()"
>
Agent anlegen
</UButton>
</form>
</UCard>
<UAlert
v-if="generatedToken"
color="warning"
variant="soft"
icon="i-heroicons-key"
title="Agent-Token"
description="Diesen Token jetzt in die .env des lokalen Agenten übernehmen. Später wird er nicht erneut angezeigt."
>
<template #description>
<code class="mt-2 block break-all rounded bg-muted px-2 py-1 text-xs">{{ generatedToken }}</code>
</template>
</UAlert>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center justify-between gap-3">
@@ -276,7 +234,8 @@ onMounted(async () => {
<h2 class="text-base font-semibold text-highlighted">{{ selectedAgent.name }}</h2>
<p class="text-xs text-muted">Token-Präfix {{ selectedAgent.tokenPrefix }} · zuletzt gesehen {{ formatDate(selectedAgent.lastSeenAt) }}</p>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<UButton color="neutral" variant="outline" size="sm" @click="openSetup(selectedAgent)">Einrichtung öffnen</UButton>
<UBadge :color="isOnline(selectedAgent) ? 'success' : 'neutral'" variant="soft">
{{ isOnline(selectedAgent) ? "Online" : "Offline" }}
</UBadge>
@@ -315,6 +274,10 @@ onMounted(async () => {
/>
</UFormField>
<UButton color="neutral" variant="soft" @click="applyIx1600Preset">
ScanSnap iX1600: A4, Duplex, PDF
</UButton>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField label="Format">
<USelectMenu
@@ -342,6 +305,13 @@ onMounted(async () => {
</UFormField>
</div>
<UFormField label="Papierformat">
<USelectMenu v-model="editState.paperSize" :items="[
{ label: 'A4', value: 'A4' }, { label: 'Letter', value: 'Letter' },
{ label: 'Scanner-Vorgabe', value: 'auto' }
]" value-key="value" label-key="label" />
</UFormField>
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
<UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" />
<UFormField label="Profil">

View File

@@ -1896,6 +1896,31 @@ const saveDocument = async (state, resetup = false) => {
if (resetup) await setupPage()
}
const saveFinalizedCostCentres = async () => {
const rowCostCentres = itemInfo.value.rows
.filter(row => row?.id)
.map(row => ({
rowId: String(row.id),
costcentre: row.costCentre || null,
}))
const result = await $api(`/api/functions/outgoing-documents/${itemInfo.value.id}/cost-centres`, {
method: "PUT",
body: {
costcentre: itemInfo.value.costcentre || null,
rowCostCentres,
},
})
itemInfo.value.costcentre = result.document.costcentre
itemInfo.value.rows = normalizeCreatedDocumentRows(result.document.rows)
toast.add({
title: "Kostenstellen gespeichert",
description: "Die Kostenstellen des fertiggestellten Belegs wurden aktualisiert.",
color: "success",
})
}
const selectedTab = ref("0")
const closeDocument = async () => {
@@ -2077,7 +2102,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<UButton
icon="i-mdi-content-save"
@click="saveDocument('Entwurf',true)"
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode"
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
:disabled="!itemInfo.customer"
>
Speichern
@@ -2092,10 +2117,17 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</UButton>
<UButton
@click="closeDocument"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
>
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
</UButton>
<UButton
icon="i-heroicons-tag"
@click="saveFinalizedCostCentres"
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Gebucht'"
>
Kostenstellen speichern
</UButton>
<UButton
icon="i-mdi-content-save"
@click="saveSerialInvoice"

View File

@@ -652,7 +652,7 @@ const executeSerialInvoices = async () => {
toast.add({
title: 'Ausführung gestartet',
description: `${res.length} Rechnungen werden im Hintergrund generiert.`,
description: `${res.results.length} Rechnungen wurden für den Lauf vorbereitet.`,
icon: 'i-heroicons-check-circle',
color: 'green'
})

View File

@@ -185,7 +185,9 @@ const resolvedPlanningBoardConfig = computed(() => {
const normalizedEndTime = /^\d{2}:\d{2}$/.test(String(config.endTime || "")) ? String(config.endTime) : "21:00"
const normalizedSlotMinutes = Number(config.slotMinutes)
const normalizedWeekStartsOn = Number(config.weekStartsOn)
const normalizedWeekEndsOn = Number(config.weekEndsOn)
const allowedSlotMinutes = [15, 30, 60, 120, 180]
const allowedWeekEndDays = [5, 6, 0]
return {
startTime: normalizedStartTime,
@@ -193,10 +195,24 @@ const resolvedPlanningBoardConfig = computed(() => {
slotMinutes: allowedSlotMinutes.includes(normalizedSlotMinutes) ? normalizedSlotMinutes : 180,
weekStartsOn: Number.isInteger(normalizedWeekStartsOn) && normalizedWeekStartsOn >= 1 && normalizedWeekStartsOn <= 5
? normalizedWeekStartsOn
: 1
: 1,
weekEndsOn: allowedWeekEndDays.includes(normalizedWeekEndsOn) ? normalizedWeekEndsOn : 5
}
})
const planningBoardHiddenDays = computed(() => {
const visibleDays = new Set()
let day = resolvedPlanningBoardConfig.value.weekStartsOn
for (let index = 0; index < 7; index += 1) {
visibleDays.add(day)
if (day === resolvedPlanningBoardConfig.value.weekEndsOn) break
day = (day + 1) % 7
}
return [0, 1, 2, 3, 4, 5, 6].filter((weekday) => !visibleDays.has(weekday))
})
const resolvedQuickEntryConfig = computed(() => {
const config = tenantCalendarConfig.value?.quickEntry || {}
@@ -358,7 +374,7 @@ const calendarOptions = computed(() => ({
buttonText: "Woche",
slotDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
snapDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
weekends: false,
hiddenDays: planningBoardHiddenDays.value,
slotMinTime: `${resolvedPlanningBoardConfig.value.startTime}:00`,
slotMaxTime: `${resolvedPlanningBoardConfig.value.endTime}:00`
},

View File

@@ -49,11 +49,12 @@ const resources = {
}
}
const numberRanges = ref(auth.activeTenantData.numberRanges || {})
const createNumberRanges = (ranges = {}) => {
const freshRanges = structuredClone(ranges)
Object.keys(resources).forEach((key) => {
if (!numberRanges.value[key]) {
numberRanges.value[key] = {
if (!freshRanges[key]) {
freshRanges[key] = {
prefix: "",
suffix: "",
nextNumber: 1000
@@ -61,17 +62,41 @@ Object.keys(resources).forEach((key) => {
}
})
const updateNumberRanges = async (range) => {
return freshRanges
}
const res = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
const numberRanges = ref({})
const loading = ref(true)
const loadNumberRanges = async () => {
loading.value = true
try {
const tenant = await useNuxtApp().$api("/api/tenant")
numberRanges.value = createNumberRanges(tenant?.numberRanges || {})
if (tenant?.id) {
auth.activeTenantData = tenant
}
} finally {
loading.value = false
}
}
onMounted(loadNumberRanges)
const updateNumberRanges = async (range) => {
const tenant = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
method: "PUT",
body: {
numberRange: numberRanges.value[range]
}
})
console.log(res)
if (tenant?.id) {
auth.activeTenantData = tenant
numberRanges.value = createNumberRanges(tenant.numberRanges || {})
}
}
@@ -92,7 +117,13 @@ const updateNumberRanges = async (range) => {
/>
</UDashboardToolbar>
<UProgress
v-if="loading"
animation="carousel"
class="m-5 w-1/2"
/>
<table
v-else
class="m-3"
>
<tr class="text-left">
@@ -103,6 +134,7 @@ const updateNumberRanges = async (range) => {
</tr>
<tr
v-for="key in Object.keys(resources)"
:key="key"
>
<td>{{resources[key].label}}</td>
<td>

View File

@@ -138,11 +138,18 @@ const planningBoardWeekStartOptions = [
{ label: "Freitag", value: 5 }
]
const planningBoardWeekEndOptions = [
{ label: "Freitag", value: 5 },
{ label: "Samstag", value: 6 },
{ label: "Sonntag", value: 0 }
]
const planningBoardConfig = reactive({
startTime: auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00",
endTime: auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00",
slotMinutes: auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180,
weekStartsOn: auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1
weekStartsOn: auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1,
weekEndsOn: auth.activeTenantData?.calendarConfig?.planningBoard?.weekEndsOn ?? 5
})
const canManageMcpTokens = computed(() => Boolean(auth.user?.is_admin || auth.hasPermission("mcp.tokens.write")))
@@ -248,6 +255,7 @@ const setupPage = async () => {
planningBoardConfig.endTime = auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00"
planningBoardConfig.slotMinutes = auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
planningBoardConfig.weekStartsOn = auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1
planningBoardConfig.weekEndsOn = auth.activeTenantData?.calendarConfig?.planningBoard?.weekEndsOn ?? 5
}
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
@@ -582,7 +590,8 @@ const savePlanningBoardConfig = async () => {
startTime: planningBoardConfig.startTime || "06:00",
endTime: planningBoardConfig.endTime || "21:00",
slotMinutes: Number(planningBoardConfig.slotMinutes) || 180,
weekStartsOn: Number(planningBoardConfig.weekStartsOn)
weekStartsOn: Number(planningBoardConfig.weekStartsOn),
weekEndsOn: Number(planningBoardConfig.weekEndsOn)
}
}
})
@@ -789,6 +798,17 @@ onMounted(() => {
/>
</UFormField>
<UFormField
label="Letzter angezeigter Wochentag:"
>
<USelectMenu
v-model="planningBoardConfig.weekEndsOn"
:items="planningBoardWeekEndOptions"
label-key="label"
value-key="value"
/>
</UFormField>
<UButton
class="mt-1"
@click="savePlanningBoardConfig"

View File

@@ -0,0 +1,10 @@
export default defineEventHandler((event) => {
setHeader(event, 'Cache-Control', 'no-store')
const config = useRuntimeConfig(event)
return {
status: 'ok',
product: 'FEDEO',
version: config.public.fedeoVersion,
revision: config.public.fedeoRevision,
}
})

View File

@@ -1645,6 +1645,7 @@ export const useDataStore = defineStore('data', () => {
{
key: "projectNumber",
label: "Projektnummer",
inputType: "text",
sortable: true
},
{

View File

@@ -0,0 +1,129 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
const token = 'fedeo_agent_' + 'a'.repeat(64)
const scanner = 'fujitsu:ScanSnap iX1600:test'
const baseAgent = { id: agentId, name: 'Linux Büro', active: true, scannerNames: [], scanDefaults: {}, capabilities: {} }
async function setup(t, initialAgent = null, overrides = {}) {
const helpers = await import('../utils/agentInstaller.mjs')
const requests = [], notices = [], events = [], downloads = [], modals = []
const timers = new Map()
const mounted = [], unmounted = []
let timerId = 0
let reportedAgent = { ...baseAgent }
const context = vm.createContext({
...helpers, ref: vue.ref, computed: vue.computed, watch: vue.watch,
defineProps: () => ({ initialAgent }),
defineEmits: () => (...args) => events.push(args),
onMounted: handler => mounted.push(handler), onBeforeUnmount: handler => unmounted.push(handler),
useAdmin: () => ({
createInstanceAgent: async body => { requests.push(['create', body]); return { agent: { ...baseAgent }, token } },
updateInstanceAgent: async (id, body) => { requests.push(['update', id, body]); return { agent: { ...baseAgent, ...body } } },
rotateInstanceAgentToken: async id => { requests.push(['rotate', id]); return { token } },
getInstanceAgents: async () => ({ agents: [reportedAgent] }),
...overrides,
}),
useToast: () => ({ add: notice => notices.push(notice) }),
useModal: () => ({ open: (...args) => modals.push(args) }),
useRuntimeConfig: () => ({ public: { apiBase: '/backend' } }),
window: { location: { origin: 'https://fedeo.example.com' } },
bundle: JSON.parse(fs.readFileSync(__dirname + '/../assets/device-agent/bundle.json', 'utf8')),
FileScanModal: 'FileScanModal',
Blob, URL: { createObjectURL: blob => { downloads.push(blob); return 'blob:test' }, revokeObjectURL() {} },
document: { createElement: () => ({ click() {}, remove() {} }), body: { appendChild() {} } },
setTimeout: callback => { timers.set(++timerId, callback); return timerId },
clearTimeout: id => timers.delete(id), setInterval: () => -1, clearInterval() {},
})
const source = fs.readFileSync(__dirname + '/../components/AgentSetupWizard.vue', 'utf8')
.split('<script setup lang="ts">')[1].split('</script>')[0].replace(/^import .*$/gm, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }), context)
mounted.forEach(handler => handler())
await new Promise(setImmediate)
const close = () => unmounted.forEach(handler => handler())
t.after(close)
return { run: code => vm.runInContext(code, context), requests, notices, events, downloads, modals, timers, close, report: value => { reportedAgent = value } }
}
test('new setup creates one agent and downloads software with the correct reverse-proxy URL', async t => {
const ui = await setup(t)
await ui.run('prepareAgent()')
await new Promise(setImmediate)
assert.equal(ui.run('step.value'), 1)
ui.run('downloadInstaller()')
assert.equal(ui.requests.filter(item => item[0] === 'create').length, 1)
assert.equal(ui.downloads.length, 1)
assert.ok((await ui.downloads[0].text()).startsWith('#!/usr/bin/env bash'))
assert.equal(ui.run('endpointPreview.value'), 'https://fedeo.example.com/backend/instance-agent/heartbeat')
assert.equal(ui.run('downloaded.value'), true)
ui.run('backendUrl.value="https://other.example.com/backend"')
await new Promise(setImmediate)
assert.equal(ui.run('downloaded.value'), false)
})
test('existing agent requires explicit acknowledgement before token rotation', async t => {
const ui = await setup(t, { ...baseAgent })
await ui.run('prepareAgent()')
await ui.run('rotateToken()')
assert.equal(ui.requests.length, 0)
ui.run('rotateConfirmed.value=true')
await ui.run('rotateToken()')
assert.equal(ui.requests[0][0], 'rotate')
assert.equal(ui.run('tokenReady.value'), true)
assert.equal(ui.run('rotateConfirmed.value'), false)
})
test('stale or inactive agents cannot finish setup; fresh reports save the chosen duplex scanner', async t => {
const ui = await setup(t, { ...baseAgent })
ui.report({ ...baseAgent, lastSeenAt: new Date(Date.now() - 300000).toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
ui.run('step.value=2; checkingSince.value=Date.now()-1000')
await ui.run('checkConnection()')
await ui.run('finishSetup()')
assert.equal(ui.run('online.value'), false)
assert.equal(ui.requests.length, 0)
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
await ui.run('checkConnection()')
ui.run('profile.value="ix1600"; step.value=3')
await ui.run('finishSetup()')
assert.equal(ui.run('saved.value'), true)
assert.equal(ui.requests[0][2].preferredScannerName, scanner)
assert.equal(ui.requests[0][2].scanDefaults.source, 'ADF Duplex')
assert.equal(ui.run('token.value'), '')
assert.equal(ui.timers.size, 0)
ui.run('openTestScan()')
assert.equal(ui.modals[0][1].scanData.agentId, agentId)
})
test('online agent without a detected scanner remains incomplete', async t => {
const ui = await setup(t, { ...baseAgent })
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), capabilities: { scan: true } })
await ui.run('checkConnection()')
await ui.run('finishSetup()')
assert.equal(ui.run('online.value'), true)
assert.equal(ui.run('readyToSave.value'), false)
assert.equal(ui.requests.length, 0)
})
test('connection errors stop automatic requests and are visible for retry', async t => {
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: async () => { throw Error('offline') } })
ui.run('step.value=2')
await ui.run('checkConnection()')
assert.equal(ui.run('connectionError.value'), 'offline')
assert.equal(ui.timers.size, 0)
})
test('closing setup clears credentials and does not schedule polling after an in-flight response', async t => {
let resolve
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: () => new Promise(done => { resolve = done }) })
ui.run('step.value=2; token.value="' + token + '"')
const pending = ui.run('checkConnection()')
ui.close()
resolve({ agents: [{ ...baseAgent, lastSeenAt: new Date().toISOString() }] })
await pending
assert.equal(ui.run('token.value'), '')
assert.equal(ui.timers.size, 0)
})

View File

@@ -0,0 +1,85 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
async function setup(getJob, scanData = {}, agents = [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }]) {
const notices = []
const requests = []
let unmount
const context = vm.createContext({
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch,
onBeforeUnmount: handler => { unmount = handler },
defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document', ...scanData } }),
defineEmits: () => () => {},
useModal: () => ({ close() {} }),
useToast: () => ({ add: notice => notices.push(notice) }),
setTimeout: callback => queueMicrotask(callback),
useNuxtApp: () => ({ $api: async (url, options) => {
requests.push({ url, options })
if (url === '/api/instance-agents') return { agents }
if (url === '/api/scan-jobs') return { job: { id: 'job' } }
return getJob()
} }),
})
const source = fs.readFileSync(`${__dirname}/../components/FileScanModal.vue`, 'utf8').split('<script setup>')[1].split('</script>')[0]
vm.runInContext(source, context)
await new Promise(setImmediate)
return { run: code => vm.runInContext(code, context), notices, requests, close: () => unmount() }
}
test('duplex scanning continues past the former three-minute polling limit', async () => {
let polls = 0
const ui = await setup(() => ({ job: { status: ++polls > 95 ? 'completed' : 'running', agentMessage: '4 Seiten gescannt.' } }))
await ui.run('startScan()')
assert.equal(polls, 96)
assert.equal(ui.notices[0].title, 'Scan gespeichert')
const body = ui.requests.find(request => request.url === '/api/scan-jobs').options.body
assert.equal(body.settings.source, 'ADF Duplex')
assert.equal(body.settings.paperSize, 'A4')
assert.equal(body.target.folder, 'folder-1')
})
test('temporary status connection failures do not create duplicate scans', async () => {
let polls = 0
const ui = await setup(() => {
if (++polls < 3) throw Error('offline')
return { job: { status: 'completed' } }
})
await ui.run('startScan()')
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 1)
assert.equal(ui.notices[0].title, 'Scan gespeichert')
})
test('closing the modal stops polling without reporting that a background scan failed', async () => {
const ui = await setup(() => { throw Error('must not poll') })
ui.close()
assert.equal(await ui.run('waitForJob("job")'), null)
assert.equal(ui.notices.length, 0)
})
test('iX1600 preset resets image settings and normalizes the output filename to PDF', async () => {
const ui = await setup(() => ({}))
ui.run('scanForm.format="png"; scanForm.source="Flatbed"; scanForm.filename="document.png"; scanForm.postprocess=true')
await new Promise(setImmediate)
ui.run('applyIx1600Preset()')
await new Promise(setImmediate)
assert.equal(ui.run('scanForm.format'), 'pdf')
assert.equal(ui.run('scanForm.filename'), 'document.pdf')
assert.equal(ui.run('scanForm.postprocess'), false)
assert.equal(ui.run('scanForm.resolution'), 300)
})
test('setup test scan uses the requested agent even when another agent is first', async () => {
const agents = ['first', 'configured'].map(id => ({ id, active: true, capabilities: { scan: true }, scannerNames: ['scanner-' + id] }))
const ui = await setup(() => ({ job: { status: 'completed' } }), { agentId: 'configured' }, agents)
await ui.run('startScan()')
assert.equal(ui.requests.find(request => request.url === '/api/scan-jobs').options.body.agentId, 'configured')
})
test('an unavailable setup agent never silently scans with another device', async () => {
const ui = await setup(() => ({}), { agentId: 'missing' })
await ui.run('startScan()')
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0)
})

View File

@@ -0,0 +1,48 @@
export function agentBackendUrl(value, origin) {
if (/[\r\n\t]/.test(String(value || ''))) throw new Error('Die Serveradresse darf keine Steuerzeichen enthalten.')
const url = new URL(String(value || '/').trim(), origin)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
throw new Error('Bitte eine HTTP(S)-Adresse ohne Zugangsdaten, Suchparameter oder Fragment eingeben.')
}
return url.toString().replace(/\/+$/, '')
}
function base64(value) {
const bytes = new TextEncoder().encode(value)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
export function renderAgentInstaller(bundle, options) {
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(options.agentId)) {
throw new Error('Ungültige Agent-ID')
}
if (!/^fedeo_agent_[a-f0-9]{64}$/.test(options.token)) throw new Error('Bitte den vollständigen Agent-Token eingeben.')
const url = agentBackendUrl(options.backendUrl)
if (/\s|["'`\\]/.test(url)) throw new Error('Die Serveradresse enthält ungültige Zeichen.')
const configuration = [
`FEDEO_URL=${url}`, `FEDEO_AGENT_TOKEN=${options.token}`,
'FEDEO_POLL_SECONDS=5', 'FEDEO_SCAN_FORMAT=pdf', 'FEDEO_SCAN_RESOLUTION=300',
'FEDEO_SCAN_MODE=Color', 'FEDEO_SCAN_TIMEOUT_SECONDS=900',
'FEDEO_SCAN_POSTPROCESS=false', 'FEDEO_SCAN_POSTPROCESS_PROFILE=document', '',
].join('\n')
const replacements = {
AGENT_ID: options.agentId, AUTOSTART: String(Boolean(options.autostart)),
PAYLOAD_BASE64: base64(JSON.stringify(bundle.files)), CONFIG_BASE64: base64(configuration),
}
return bundle.installerTemplate.replace(/@@([A-Z0-9_]+)@@/g, (_, key) => {
if (!(key in replacements)) throw new Error('Unbekannter Platzhalter im Installationspaket')
return replacements[key]
})
}
export const ix1600ScanDefaults = {
format: 'pdf', resolution: 300, mode: 'Color', source: 'ADF Duplex',
paperSize: 'A4', postprocess: false, postprocessProfile: 'document',
}
export function agentSetupReady(agent, since = 0, now = Date.now()) {
const lastSeen = Date.parse(agent?.lastSeenAt || '')
return Boolean(agent?.active && Number.isFinite(lastSeen) && lastSeen >= since && now - lastSeen < 120000)
}

6225
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "fedeo-release-tools",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"test": "node --test scripts/release/tests/*.test.mjs && npm run test:update",
"test:update": "python3 -B -m unittest discover -s scripts/update/tests -v",
"commits:check": "node scripts/release/cli.mjs check",
"release:preview": "node scripts/release/cli.mjs preview",
"release:ci": "node scripts/release/ci.mjs"
},
"devDependencies": {
"@semantic-release/commit-analyzer": "13.0.1",
"@semantic-release/release-notes-generator": "14.1.0",
"conventional-changelog-conventionalcommits": "9.1.0",
"semver": "7.7.2"
}
}

View File

@@ -2,13 +2,17 @@ FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
COPY apps/admin/package.json apps/admin/package.json
RUN npm install
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build --workspace @fedeo/push-admin
FROM node:22-alpine
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/admin/.output ./.output

View File

@@ -3,13 +3,17 @@ WORKDIR /app
COPY package.json package-lock.json* ./
COPY apps/api/package.json apps/api/package.json
COPY packages/db/package.json packages/db/package.json
RUN npm install
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build --workspace @fedeo/push-db && npm run build --workspace @fedeo/push-api
FROM node:22-alpine
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /app
ENV NODE_ENV=production
ENV DRIZZLE_MIGRATIONS_FOLDER=/app/packages/db/drizzle

16
release.config.json Normal file
View File

@@ -0,0 +1,16 @@
{
"initialVersion": "3.0.0",
"bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18",
"legacyHistoryHeads": ["e7554fa2cc649f69dce4880abe76c6fab0f82700"],
"branch": "main",
"repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO",
"registry": "git.federspiel.tech/flfeders/fedeo",
"images": [
{ "name": "backend", "context": "backend", "dockerfile": "backend/Dockerfile" },
{ "name": "frontend", "context": "frontend", "dockerfile": "frontend/Dockerfile" },
{ "name": "docs", "context": ".", "dockerfile": "docs-site/Dockerfile" },
{ "name": "website", "context": "website", "dockerfile": "website/Dockerfile" },
{ "name": "central-services-api", "context": "push-server", "dockerfile": "push-server/apps/api/Dockerfile" },
{ "name": "central-services-admin", "context": "push-server", "dockerfile": "push-server/apps/admin/Dockerfile" }
]
}

115
scripts/release/ci.mjs Normal file
View File

@@ -0,0 +1,115 @@
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createPlan, git, readConfig, stableTag } from './lib.mjs';
import { Gitea } from './gitea.mjs';
import { acquireLock } from './lock.mjs';
import { publishRelease } from './publish.mjs';
const config = readConfig();
const outputDir = '.release';
let unlock;
function docker(args, capture = false) {
const result = spawnSync('docker', args, { encoding: 'utf8', stdio: capture ? 'pipe' : 'inherit', maxBuffer: 16 * 1024 * 1024 });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Docker ${args.slice(0, 3).join(' ')} fehlgeschlagen${capture ? `: ${result.stderr}` : '.'}`);
return result.stdout?.trim();
}
function imageDigest(reference, optional = false) {
try {
return JSON.parse(docker(['buildx', 'imagetools', 'inspect', reference, '--format', '{{json .Manifest}}'], true)).digest;
} catch (error) {
if (optional && /manifest unknown|not found|no such manifest/i.test(error.message)) return null;
throw error;
}
}
async function promote(image, tags, immutable) {
for (const tag of tags) {
const reference = `${image.repository}:${tag}`;
if (immutable) {
const existing = imageDigest(reference, true);
if (existing === image.digest) continue;
if (existing) throw new Error(`Image ${reference} existiert mit anderem Digest; Überschreiben verweigert.`);
}
docker(['buildx', 'imagetools', 'create', '--prefer-index=false', '--tag', reference, `${image.repository}@${image.digest}`]);
if (imageDigest(reference) !== image.digest) throw new Error(`Digest-Prüfung für ${reference} fehlgeschlagen.`);
}
}
async function buildImages(plan, version, stagingTag = `build-${randomUUID()}`) {
const images = [];
for (const component of config.images) {
const repository = `${config.registry}/${component.name}`;
const metadata = `${outputDir}/${component.name}.json`;
console.log(`Baue ${component.name} ${version} (${plan.sha.slice(0, 12)})`);
docker(['buildx', 'build', '--push', '--platform', 'linux/amd64', '--provenance=false',
'--file', component.dockerfile, '--metadata-file', metadata, '--tag', `${repository}:${stagingTag}`,
'--build-arg', `FEDEO_VERSION=${version}`, '--build-arg', `FEDEO_REVISION=${plan.sha}`,
'--label', `org.opencontainers.image.version=${version}`, '--label', `org.opencontainers.image.revision=${plan.sha}`,
'--label', `org.opencontainers.image.source=${config.repositoryUrl}`, component.context]);
const digest = JSON.parse(readFileSync(metadata, 'utf8'))['containerimage.digest'];
if (!/^sha256:[a-f0-9]{64}$/.test(digest || '')) throw new Error(`Fehlender Digest für ${component.name}.`);
images.push({ name: component.name, repository, digest });
}
return images;
}
try {
const ref = process.env.FEDEO_REF;
if (process.env.CI !== 'true' || !['push', 'workflow_dispatch'].includes(process.env.FEDEO_EVENT_NAME) ||
![`refs/heads/${config.branch}`, 'refs/heads/dev'].includes(ref)) {
throw new Error('Veröffentlichung nur im Gitea-Workflow auf dev/main. Lokal: npm run release:preview.');
}
const sha = git(['rev-parse', 'HEAD']);
if (sha !== process.env.FEDEO_SHA) throw new Error('Checkout stimmt nicht mit dem Workflow-Commit überein.');
const stable = ref === `refs/heads/${config.branch}`;
const token = process.env.GITEA_TOKEN;
if (stable && !token) throw new Error('Für main einen RELEASE_TOKEN oder CI_TOKEN mit write:repository konfigurieren (siehe docs/releases.md).');
mkdirSync(outputDir, { recursive: true });
if (stable) unlock = await acquireLock();
git(['fetch', 'origin', `refs/heads/${config.branch}:refs/remotes/origin/${config.branch}`, '--tags']);
if (stable) {
const api = new Gitea(config.repositoryUrl, token);
const releases = await api.releases();
const pendingForThisCommit = releases.some(release => release.draft && stableTag.test(release.tag_name) && release.target_commitish === sha);
if (git(['rev-parse', `origin/${config.branch}`]) !== sha && !pendingForThisCommit) {
console.log('Ein neuerer main-Commit liegt vor. Dessen Workflow übernimmt die ausstehenden Änderungen.');
} else {
const plan = await createPlan();
writeFileSync(`${outputDir}/plan.json`, JSON.stringify(plan, null, 2));
if (!plan.version) console.log('Keine releasewirksamen Änderungen seit dem letzten Tag.');
else {
const result = await publishRelease({ plan, config, api, build: version => buildImages(plan, version), promote });
writeFileSync(`${outputDir}/release-manifest.json`, JSON.stringify(result.manifest, null, 2));
console.log(`FEDEO ${plan.version} veröffentlicht: ${config.repositoryUrl}/releases/tag/${plan.tag}`);
}
}
} else {
const plan = await createPlan();
// Separate snapshots let overlapping dev runs build without a repository write lock.
const snapshotTag = `dev-sha-${sha}-${randomUUID()}`;
const images = await buildImages(plan, plan.developmentVersion, snapshotTag);
console.log(`Entwicklungsbuild: ${snapshotTag}`);
for (const image of images) {
// Check each update so an outdated run stops moving the mutable dev channel.
const remoteDev = git(['ls-remote', 'origin', 'refs/heads/dev']).split(/\s/)[0];
if (remoteDev !== sha) {
console.log('Ein neuerer dev-Commit liegt vor; der dev-Kanal wird nicht weiter aktualisiert.');
break;
}
await promote(image, ['dev'], false);
}
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
if (unlock) {
try { unlock(); }
catch (error) { console.error(`Release-Sperre konnte nicht entfernt werden: ${error.message}`); process.exitCode = 1; }
}
}

21
scripts/release/cli.mjs Normal file
View File

@@ -0,0 +1,21 @@
import { assertBootstrap, checkCommits, commitsSince, createPlan, readConfig } from './lib.mjs';
try {
const command = process.argv[2];
if (command === 'check') {
const config = readConfig();
assertBootstrap(config, 'HEAD', process.cwd());
const commits = commitsSince(config.bootstrapCommit, 'HEAD', process.cwd(), config.legacyHistoryHeads);
checkCommits(commits);
console.log(`${commits.length} Commit-Nachrichten geprüft.`);
} else if (command === 'preview') {
const plan = await createPlan();
console.log(JSON.stringify(plan, null, 2));
console.log('\nLokale Vorschau; berücksichtigt die lokal vorhandenen Tags. Zum Aktualisieren: git fetch origin --tags.');
} else {
throw new Error('Aufruf: node scripts/release/cli.mjs check|preview');
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}

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