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
This commit was merged in pull request #220.
This commit is contained in:
2026-09-20 16:10:46 +00:00
768 changed files with 245540 additions and 10615 deletions

10
.dockerignore Normal file
View File

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

121
.env.example Normal file
View File

@@ -0,0 +1,121 @@
# 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
DB_NAME=fedeo
DB_USER=fedeo
DB_PASSWORD=change-this-db-password
DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo
MINIO_ROOT_USER=fedeo-minio
MINIO_ROOT_PASSWORD=change-this-minio-password
MINIO_BUCKET=fedeo
HOST=0.0.0.0
PORT=3100
FEDEO_RUN_MIGRATIONS=true
COOKIE_SECRET=change-this-cookie-secret
JWT_SECRET=change-this-jwt-secret
ENCRYPTION_KEY=change-this-encryption-key
MAILER_SMTP_HOST=smtp.example.com
MAILER_SMTP_PORT=587
MAILER_SMTP_SSL=false
MAILER_SMTP_USER=mailer@example.com
MAILER_SMTP_PASS=change-this-mail-password
MAILER_FROM=FEDEO <no-reply@example.com>
# Desktop Push per Web Push. Schlüssel können mit
# `npx web-push generate-vapid-keys` erzeugt werden.
WEB_PUSH_PUBLIC_KEY=replace-this-web-push-public-key
WEB_PUSH_PRIVATE_KEY=replace-this-web-push-private-key
WEB_PUSH_SUBJECT=mailto:admin@example.com
S3_ENDPOINT=http://minio:9000
S3_REGION=eu-central-1
S3_ACCESS_KEY=fedeo-minio
S3_SECRET_KEY=change-this-minio-password
S3_BUCKET=fedeo
# Datei-Backend. S3 bleibt aktuell der Standard; Seafile kann als externer
# Dateidienst angebunden werden, sobald der Backend-Umbau aktiviert ist.
FEDEO_FILE_BACKEND=s3
# Externer Seafile-Dienst, nicht Teil des Standard-Compose-Stacks.
SEAFILE_BASE_URL=https://files.example.com
SEAFILE_INTERNAL_URL=https://files.example.com
SEAFILE_ADMIN_EMAIL=admin@example.com
SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend
GOCARDLESS_BASE_URL=https://api.gocardless.com
GOCARDLESS_SECRET_ID=replace-this
GOCARDLESS_SECRET_KEY=replace-this
DOKUBOX_IMAP_HOST=imap.example.com
DOKUBOX_IMAP_PORT=993
DOKUBOX_IMAP_SECURE=true
DOKUBOX_IMAP_USER=dokubox@example.com
DOKUBOX_IMAP_PASSWORD=change-this-imap-password
OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
# Interner Prometheus Node Exporter für die Admin-Systemstatusseite.
NODE_EXPORTER_URL=http://node-exporter:9100
# Lokaler Asterisk-Test für SIP/Voice. Aktivieren, wenn das Compose-Profil
# `telephony-dev` genutzt wird.
TELEPHONY_ENABLED=false
ASTERISK_IMAGE=andrius/asterisk:20
TELEPHONY_ASTERISK_HTTP_URL=http://asterisk-dev:8088/ws
TELEPHONY_ASTERISK_WS_URL=ws://localhost:8088/ws
TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated
TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev
TELEPHONY_ASTERISK_AMI_PORT=5038
TELEPHONY_ASTERISK_AMI_USER=fedeo
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
TELEPHONY_SIP_DOMAIN=localhost
TELEPHONY_TEST_EXTENSION=1001
TELEPHONY_TEST_PASSWORD=fedeo-test-1001
TELEPHONY_TEST_EXTENSION_2=1002
TELEPHONY_TEST_PASSWORD_2=fedeo-test-1002
TELEPHONY_ECHO_EXTENSION=600
TELEPHONY_DEV_WS_PORT=8088
TELEPHONY_DEV_AMI_PORT=5038
TELEPHONY_DEV_SIP_PORT=5060
TELEPHONY_DEV_RTP_MIN_PORT=10000
TELEPHONY_DEV_RTP_MAX_PORT=10100
TELEPHONY_ASTERISK_EXTERNAL_SIGNALING_ADDRESS=
TELEPHONY_ASTERISK_EXTERNAL_MEDIA_ADDRESS=
# Externe Telefonie über Telekom/tel.t-online.de. Keine echten Zugangsdaten
# einchecken. SIP-ID ist in der Regel die Rufnummer mit Vorwahl ohne Leerzeichen
# und ohne Sonderzeichen, z. B. 0301234567. Wenn dein Anschluss noch die
# Internet-Zugangsdaten als Auth-User nutzt, kann TELEPHONY_TELEKOM_AUTH_USER
# aus Anschlusskennung + Zugangsnummer + # + Mitbenutzernummer + @t-online.de
# gebildet werden.
TELEPHONY_EXTERNAL_PROVIDER=
TELEPHONY_EXTERNAL_ENABLED=false
TELEPHONY_EXTERNAL_INBOUND_EXTENSION=1001
TELEPHONY_TELEKOM_ENABLED=false
TELEPHONY_TELEKOM_REGISTRAR=tel.t-online.de
TELEPHONY_TELEKOM_SIP_USER=
TELEPHONY_TELEKOM_AUTH_USER=
TELEPHONY_TELEKOM_PASSWORD=
TELEPHONY_TELEKOM_CALLER_ID=
TELEPHONY_TELEKOM_INBOUND_EXTENSION=1001
TELEPHONY_TELEKOM_OUTBOUND_PREFIX=0
# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN

View File

@@ -1,77 +1,53 @@
name: Build and Push Docker Images
run-name: Build Backend & Frontend 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)
IMAGE_NAME: ${{ github.repository }}
ACTOR: flfeders
on:
push:
branches: [dev, main]
workflow_dispatch:
jobs:
build-backend:
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' }}
node-version: '22'
- run: npm ci --ignore-scripts --no-audit --no-fund
- name: Test release planning and recovery
run: npm test
- name: Check Conventional Commits
run: npm run commits:check
- name: Build and push Backend
uses: docker/build-push-action@v4
with:
context: ./backend # Hier wird der Ordner gewechselt (wie 'cd backend')
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
build-frontend:
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
# Prefer repository secrets; retain the existing variable during migration.
token: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_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 }}
registry: git.federspiel.tech
username: flfeders
password: ${{ secrets.CI_TOKEN || vars.CI_TOKEN }}
- name: Build all images and publish main releases
# Gitea 1.25 ignores concurrency. ci.mjs uses an atomic Git ref lock instead.
env:
CI: 'true'
FEDEO_REF: ${{ github.ref }}
FEDEO_SHA: ${{ github.sha }}
FEDEO_EVENT_NAME: ${{ github.event_name }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }}
run: npm run release:ci

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.env
node_modules/
.nuxt/
.output/
/.release/
# Lokale Runtime-Daten und generierte Konfigurationen
matrix/postgres/
matrix/synapse/
matrix/dev/postgres/
matrix/dev/synapse/

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/FEDEO.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/FEDEO.iml" filepath="$PROJECT_DIR$/.idea/FEDEO.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

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

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.

647
README.md
View File

@@ -1,109 +1,554 @@
# FEDEO Hosting Guide
Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO.
## Versionen und Releases
# Docker Compose Setup
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.
## ENV Vars
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.
- DOMAIN
- PDF_LICENSE
- DB_PASS
- DB_USER
- CONTACT_EMAIL
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.
## Docker Compose File
~~~
## Architektur
Der Stack besteht aus:
- `frontend`: Nuxt-Frontend auf Port `3000`
- `backend`: Node/Fastify-API auf Port `3100`
- `db`: PostgreSQL
- `traefik`: Reverse Proxy mit automatischen Let's-Encrypt-Zertifikaten
- optional `minio`: S3-kompatibler Objektspeicher fur Dateiuploads
Die Konfiguration erfolgt uber Umgebungsvariablen beziehungsweise eine `.env`-Datei im Deploy-Verzeichnis.
## Voraussetzungen
Vor dem Deployment sollten folgende Punkte erfullt sein:
- Ein Linux-Server oder VPS mit offentlichen Ports `80` und `443`
- Docker Engine inkl. Compose Plugin
- Eine Domain, die auf den Server zeigt, z. B. `app.example.com`
- Optional: SMTP-Zugang fur E-Mails
- Optional: S3-Bucket oder MinIO fur Dateispeicher
Empfohlen:
- mindestens 2 vCPU
- mindestens 4 GB RAM
- SSD-Speicher fur PostgreSQL und Dateiuploads
## DNS und Netzwerk
Lege mindestens einen A- oder AAAA-Record an:
- `app.example.com -> <SERVER-IP>`
Traefik terminiert TLS direkt im Compose-Stack. Es ist kein zusatzlicher Reverse Proxy davor erforderlich.
## Benotigte Backend-Umgebungsvariablen
Das Backend erwartet mindestens diese Umgebungsvariablen:
- `COOKIE_SECRET`
- `JWT_SECRET`
- `PORT`
- `HOST`
- `DATABASE_URL`
- `S3_BUCKET`
- `ENCRYPTION_KEY`
- `MAILER_SMTP_HOST`
- `MAILER_SMTP_PORT`
- `MAILER_SMTP_SSL`
- `MAILER_SMTP_USER`
- `MAILER_SMTP_PASS`
- `MAILER_FROM`
- `S3_ENDPOINT`
- `S3_REGION`
- `S3_ACCESS_KEY`
- `S3_SECRET_KEY`
- `M2M_API_KEY`
- `API_BASE_URL`
- `GOCARDLESS_BASE_URL`
- `GOCARDLESS_SECRET_ID`
- `GOCARDLESS_SECRET_KEY`
- `DOKUBOX_IMAP_HOST`
- `DOKUBOX_IMAP_PORT`
- `DOKUBOX_IMAP_SECURE`
- `DOKUBOX_IMAP_USER`
- `DOKUBOX_IMAP_PASSWORD`
- `OPENAI_API_KEY`
- `STIRLING_API_KEY`
Minimal wichtige Werte fur den ersten Start:
- `HOST=0.0.0.0`
- `PORT=3100`
- `DATABASE_URL=postgres://fedeo:<starkes-passwort>@db:5432/fedeo`
- `API_BASE_URL=https://app.example.com/backend`
Wenn du MinIO verwendest, setze zusatzlich:
- `S3_ENDPOINT=http://minio:9000`
- `S3_REGION=eu-central-1`
- `S3_ACCESS_KEY=<MINIO_ROOT_USER>`
- `S3_SECRET_KEY=<MINIO_ROOT_PASSWORD>`
- `S3_BUCKET=fedeo`
## Deploy-Struktur
Deploye den Stack in einem eigenen Betriebsverzeichnis. Der Selfhost-Installer lädt dafür nur die benötigten Betriebsdateien und klont nicht das komplette Repository.
Beispiel für die manuelle Vorbereitung:
```bash
mkdir -p /opt/fedeo/scripts
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
```
Die Verzeichnisstruktur sollte dann mindestens so aussehen:
```text
/opt/fedeo/
docker-compose.yml
.env
scripts/
traefik/
letsencrypt/
logs/
postgres/
minio/
```
Danach:
```bash
mkdir -p /opt/fedeo/traefik/letsencrypt
mkdir -p /opt/fedeo/traefik/logs
mkdir -p /opt/fedeo/postgres
mkdir -p /opt/fedeo/minio
touch /opt/fedeo/traefik/letsencrypt/acme.json
chmod 600 /opt/fedeo/traefik/letsencrypt/acme.json
```
Als Startpunkt kannst du die Beispielumgebung kopieren:
```bash
cp .env.example .env
```
Ersetze anschließend alle Platzhalter und passe mindestens `DOMAIN`, `CONTACT_EMAIL`, Datenbank-, Secret-, SMTP- und S3-Werte an. Seafile ist kein Teil des Standard-Stacks; wenn FEDEO später Seafile als File-Backend nutzen soll, zeigst du die Seafile-Variablen auf einen externen Seafile-Dienst.
Alternativ kannst du die Konfiguration geführt erzeugen lassen:
```bash
bash scripts/selfhost-setup.sh
```
Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration direkt per One-Liner vorbereiten:
```bash
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
```
Der One-Liner von der Webseite nutzt den schnellen Standardpfad und startet den Stack direkt:
```bash
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
```
Der Installer prüft Basispakete, installiert Docker auf Wunsch über das offizielle Docker-Installationsscript, lädt nur die Selfhost-Dateien nach `/opt/fedeo` und startet anschließend den geführten Setup-Assistenten.
Für den schnellen Standardpfad:
```bash
bash scripts/selfhost-setup.sh --simple
```
Für mehr Rückfragen zu SMTP, API-Schlüsseln und optionalen Diensten:
```bash
bash scripts/selfhost-setup.sh --advanced
```
Der Assistent erklärt zuerst die Selfhost-Verzeichnisstruktur, schreibt anschließend `.env`, legt persistente Verzeichnisse inklusive `traefik/letsencrypt/acme.json` an und kann den Stack optional direkt starten.
## Beispiel `.env`
Diese Datei liegt neben der `docker-compose.yml`:
```env
DOMAIN=app.example.com
CONTACT_EMAIL=admin@deine-domain.de
DB_NAME=fedeo
DB_USER=fedeo
DB_PASSWORD=change-this-db-password
DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo
MINIO_ROOT_USER=fedeo-minio
MINIO_ROOT_PASSWORD=change-this-minio-password
MINIO_BUCKET=fedeo
HOST=0.0.0.0
PORT=3100
COOKIE_SECRET=change-this-cookie-secret
JWT_SECRET=change-this-jwt-secret
ENCRYPTION_KEY=change-this-encryption-key
MAILER_SMTP_HOST=smtp.example.com
MAILER_SMTP_PORT=587
MAILER_SMTP_SSL=false
MAILER_SMTP_USER=mailer@example.com
MAILER_SMTP_PASS=change-this-mail-password
MAILER_FROM=FEDEO <no-reply@example.com>
S3_ENDPOINT=http://minio:9000
S3_REGION=eu-central-1
S3_ACCESS_KEY=fedeo-minio
S3_SECRET_KEY=change-this-minio-password
S3_BUCKET=fedeo
FEDEO_FILE_BACKEND=s3
SEAFILE_BASE_URL=https://files.example.com
SEAFILE_INTERNAL_URL=https://files.example.com
SEAFILE_ADMIN_EMAIL=admin@example.com
SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend
GOCARDLESS_BASE_URL=https://api.gocardless.com
GOCARDLESS_SECRET_ID=replace-this
GOCARDLESS_SECRET_KEY=replace-this
DOKUBOX_IMAP_HOST=imap.example.com
DOKUBOX_IMAP_PORT=993
DOKUBOX_IMAP_SECURE=true
DOKUBOX_IMAP_USER=dokubox@example.com
DOKUBOX_IMAP_PASSWORD=change-this-imap-password
OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this
# Optional: zentrale, durch den FEDEO-Betreiber bereitgestellte Dienste
FEDEO_CENTRAL_SERVICES_ENABLED=false
PUSH_SERVER_URL=https://services.fedeo.de
PUSH_SERVER_INSTANCE_ID=
PUSH_SERVER_SECRET=
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
```
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
## Docker Compose mit optionalem S3
Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist.
Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest.
```yaml
services:
frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:main
restart: always
environment:
- NUXT_PUBLIC_API_BASE=https://${DOMAIN}/backend
- NUXT_PUBLIC_PDF_LICENSE=${PDF_LICENSE}
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3000"
# Middlewares
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
# Web Entrypoint
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
- "traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
backend:
image: git.federspiel.tech/flfeders/fedeo/backend:main
restart: always
environment:
- INFISICAL_CLIENT_ID=
- INFISICAL_CLIENT_SECRET=
- NODE_ENV=production
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3100"
# Middlewares
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
# Web Entrypoint
- "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
- "traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
# db:
# image: postgres
# restart: always
# shm_size: 128mb
# environment:
# POSTGRES_PASSWORD:
# POSTGRES_USER:
# POSTGRES_DB:
# volumes:
# - ./pg-data:/var/lib/postgresql/data
# ports:
# - "5432:5432"
traefik:
image: traefik:v2.11
restart: unless-stopped
container_name: traefik
command:
- "--api.insecure=false"
- "--api.dashboard=false"
- "--api.debug=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=traefik"
- "--entrypoints.web.address=:80"
- "--entrypoints.web-secured.address=:443"
- "--accesslog=true"
- "--accesslog.filepath=/logs/access.log"
- "--accesslog.bufferingsize=5000"
- "--accesslog.fields.defaultMode=keep"
- "--accesslog.fields.headers.defaultMode=keep"
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
- "--certificatesresolvers.mytlschallenge.acme.email=${CONTACT_EMAIL}"
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
ports:
- 80:80
- 443:443
volumes:
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik/logs:/logs"
networks:
- traefik
traefik:
image: traefik:v2.11
container_name: fedeo-traefik
restart: unless-stopped
command:
- --api.insecure=false
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
- --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --accesslog=true
- --accesslog.filepath=/logs/access.log
ports:
- "80:80"
- "443:443"
volumes:
- ./traefik/letsencrypt:/letsencrypt
- ./traefik/logs:/logs
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- web
db:
image: postgres:16
container_name: fedeo-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- internal
minio:
image: minio/minio:latest
container_name: fedeo-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- ./minio:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 10
networks:
- internal
createbuckets:
image: minio/mc:latest
container_name: fedeo-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
mc mb --ignore-existing local/${MINIO_BUCKET};
mc anonymous set private local/${MINIO_BUCKET};
exit 0;
"
restart: "no"
networks:
- internal
backend:
image: git.federspiel.tech/flfeders/fedeo/backend:dev
container_name: fedeo-backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
createbuckets:
condition: service_completed_successfully
environment:
NODE_ENV: production
HOST: ${HOST}
PORT: ${PORT}
COOKIE_SECRET: ${COOKIE_SECRET}
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
DATABASE_URL: ${DATABASE_URL}
MAILER_SMTP_HOST: ${MAILER_SMTP_HOST}
MAILER_SMTP_PORT: ${MAILER_SMTP_PORT}
MAILER_SMTP_SSL: ${MAILER_SMTP_SSL}
MAILER_SMTP_USER: ${MAILER_SMTP_USER}
MAILER_SMTP_PASS: ${MAILER_SMTP_PASS}
MAILER_FROM: ${MAILER_FROM}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_REGION: ${S3_REGION}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET}
M2M_API_KEY: ${M2M_API_KEY}
API_BASE_URL: ${API_BASE_URL}
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID}
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}
DOKUBOX_IMAP_HOST: ${DOKUBOX_IMAP_HOST}
DOKUBOX_IMAP_PORT: ${DOKUBOX_IMAP_PORT}
DOKUBOX_IMAP_SECURE: ${DOKUBOX_IMAP_SECURE}
DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER}
DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD}
OPENAI_API_KEY: ${OPENAI_API_KEY}
STIRLING_API_KEY: ${STIRLING_API_KEY}
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
- traefik.http.routers.fedeo-backend.entrypoints=websecure
- traefik.http.routers.fedeo-backend.tls.certresolver=letsencrypt
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
- traefik.docker.network=fedeo_web
networks:
- web
- internal
frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
container_name: fedeo-frontend
restart: unless-stopped
depends_on:
- backend
environment:
NODE_ENV: production
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
- traefik.docker.network=fedeo_web
networks:
- web
networks:
traefik:
external: false
~~~
web:
name: fedeo_web
driver: bridge
internal:
name: fedeo_internal
driver: bridge
```
## Externe S3-Provider statt MinIO
Wenn du keinen lokalen MinIO-Container betreiben willst:
1. Entferne die Services `minio` und `createbuckets` aus der Compose-Datei.
2. Entferne im Backend `depends_on` fur `minio` und `createbuckets`.
3. Trage in `.env` die Zugangsdaten des externen S3-Dienstes ein.
Beispiel fur die relevanten Werte:
```env
S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
S3_REGION=eu-central-1
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
S3_BUCKET=fedeo
```
Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit MinIO und vielen S3-kompatiblen Providern. Bei reinem AWS S3 kann je nach Endpoint-Setup ein abweichendes Verhalten sinnvoll sein. Falls du AWS S3 einsetzen willst, sollte die S3-Initialisierung im Backend gegen den konkreten Zielprovider getestet werden.
## Start des Stacks
Im Deploy-Verzeichnis:
```bash
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
```
Danach Status prufen:
```bash
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml logs -f traefik
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml logs -f backend
```
Wenn du Migrationen manuell ausführen möchtest:
```bash
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
```
## Funktionsprufung
Nach dem ersten Start sollten mindestens diese Checks erfolgreich sein:
```bash
curl -I https://app.example.com
curl https://app.example.com/backend/health
```
Erwartung:
- Frontend liefert `200` oder `302`
- Backend liefert JSON wie `{"status":"ok"}`
Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` anmelden. Die Mandantensperre wird über `locked` gesteuert; `hasActiveLicense` wird nicht mehr für den Selfhost-Zugriff ausgewertet.
## 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:
```bash
cd /opt/fedeo
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" .env postgres minio
```
Danach die aktuellen Selfhost-Dateien laden und den Stack aktualisieren:
```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
```
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.
## Backup-Empfehlung
Regelmassig sichern:
- `./postgres`
- `./minio` falls MinIO lokal genutzt wird
- `./traefik/letsencrypt/acme.json`
- deine `.env`
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management
## Bekannte Betriebsbesonderheiten
- Das Backend startet nur sauber, wenn alle Pflichtvariablen gesetzt sind.
- Ohne korrekt gesetzte S3-Secrets funktionieren Dateiuploads und dateibasierte Funktionen nicht.
- Fur die Frontend-PDF-Funktion wird eine gueltige `NUXT_PUBLIC_PDF_LICENSE` benotigt.
- PostgreSQL ist im Projekt vorgesehen; andere SQL-Datenbanken sind in dieser Compose-Datei nicht berucksichtigt.
## Optional: Nur mit bestehender externer Infrastruktur
Wenn bereits vorhanden:
- externer Reverse Proxy
- externer PostgreSQL-Server
- externer S3-Speicher
- externe Zertifikatsverwaltung
dann konnen `traefik`, `db` und `minio` aus dem Stack entfernt werden. In diesem Fall mussen die zugehorigen Hostnamen und Zugangsdaten in der `.env` beziehungsweise im Frontend-Environment auf die externe Infrastruktur zeigen.

View File

@@ -0,0 +1,8 @@
node_modules
dist
.venv-opencv
.env
*.log
*.tmp
__pycache__
tests

View File

@@ -0,0 +1,15 @@
FEDEO_URL=https://fedeo.example.com
FEDEO_AGENT_TOKEN=fedeo_agent_REPLACE_ME
FEDEO_POLL_SECONDS=5
FEDEO_WORK_DIR=/tmp/fedeo-device-agent
FEDEO_SCANNER_NAME=
FEDEO_PRINTER_NAME=
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=
FEDEO_SCAN_POSTPROCESS_STRICT=false

7
agents/fedeo-device-agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
dist
node_modules
.venv-opencv
.env
*.log
*.tmp
__pycache__/

View File

@@ -0,0 +1,45 @@
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV FEDEO_WORK_DIR=/work
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
WORKDIR /opt/fedeo-device-agent
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
cups-client \
libgomp1 \
python3 \
python3-pip \
python3-venv \
sane-utils \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-opencv.txt ./
RUN python3 -m venv .venv-opencv \
&& .venv-opencv/bin/python -m pip install --no-cache-dir --upgrade pip \
&& .venv-opencv/bin/python -m pip install --no-cache-dir -r requirements-opencv.txt \
&& .venv-opencv/bin/python -c "import cv2, PIL, numpy"
COPY --from=build /app/dist ./dist
COPY scripts ./scripts
COPY package.json ./
RUN mkdir -p /work
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,217 @@
# FEDEO Geräte-Agent
Der FEDEO Geräte-Agent läuft lokal auf macOS, Linux oder Raspberry Pi OS. Er holt instanzweite Scan-Aufträge von FEDEO ab, führt sie auf einem lokal angeschlossenen Scanner aus und lädt das Ergebnis wieder in FEDEO hoch.
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
```bash
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
lpstat -p
```
### Linux und Raspberry Pi OS
```bash
sudo apt update
sudo apt install -y nodejs npm sane-utils python3-pil
scanimage -L
```
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
cp .env.example .env
nano .env
```
Wichtige Werte:
```env
FEDEO_URL=https://deine-fedeo-instanz
FEDEO_AGENT_TOKEN=fedeo_agent_...
FEDEO_SCANNER_NAME=
FEDEO_POLL_SECONDS=5
```
Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanner.
## Entwicklung
```bash
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.
```bash
npm run setup:opencv
```
Konfiguration:
```env
FEDEO_SCAN_POSTPROCESS=true
FEDEO_SCAN_POSTPROCESS_PROFILE=receipt
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, 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:
- `receipt`: Bons und schmale Belege werden bevorzugt hochkant zugeschnitten und kontrastiert.
- `document`: allgemeine Dokumente mit Farberhalt und moderater Verbesserung.
- `raw`: Zuschnitt/Entzerrung ohne starke Kontrastkorrektur.
## Container-Betrieb
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
```bash
cp .env.example .env
nano .env
docker compose -f docker-compose.example.yml up --build
```
Wenn FEDEO lokal auf dem Docker-Host läuft, verwende im Container nicht `localhost`, sondern:
```env
FEDEO_URL=http://host.docker.internal:3100
```
Scanner im Container prüfen:
```bash
docker compose -f docker-compose.example.yml run --rm fedeo-device-agent scanimage -L
```
Wenn der Scanner nicht sichtbar ist, hilft je nach Gerät/Host manchmal `privileged: true` im Compose-Beispiel. Auf macOS ist Docker dafür nur eingeschränkt geeignet, weil Docker Desktop USB-Scanner normalerweise nicht direkt an Linux-Container durchreichen kann. Für macOS bleibt deshalb der native Agent oder später eine signierte App der bessere Weg.
## Build
```bash
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:
- `POST /instance-agent/heartbeat`
- `GET /instance-agent/scan-jobs/next`
- `POST /instance-agent/scan-jobs/:id/status`
- `POST /instance-agent/scan-jobs/:id/upload`
## macOS Autostart
Die Vorlage liegt unter `system/macos/com.fedeo.device-agent.plist`. Nach Anpassung der Pfade kann sie als LaunchAgent installiert werden:
```bash
mkdir -p ~/Library/LaunchAgents
cp system/macos/com.fedeo.device-agent.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.fedeo.device-agent.plist
```
## Linux Autostart
Die Vorlage liegt unter `system/linux/fedeo-device-agent.service`.

View File

@@ -0,0 +1,26 @@
services:
fedeo-device-agent:
build:
context: .
image: fedeo-device-agent:local
container_name: fedeo-device-agent
restart: unless-stopped
env_file:
- .env
environment:
FEDEO_WORK_DIR: /work
FEDEO_SCAN_POSTPROCESS_PYTHON: /opt/fedeo-device-agent/.venv-opencv/bin/python
FEDEO_SCAN_POSTPROCESS_STRICT: "false"
volumes:
- fedeo-device-agent-work:/work
# Optional fuer CUPS-Druck ueber den Host:
# - /var/run/cups/cups.sock:/var/run/cups/cups.sock
extra_hosts:
- "host.docker.internal:host-gateway"
devices:
- /dev/bus/usb:/dev/bus/usb
# Falls SANE den Scanner trotz devices-Mapping nicht sieht, testweise aktivieren:
# privileged: true
volumes:
fedeo-device-agent-work:

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

@@ -0,0 +1,30 @@
{
"name": "@fedeo/device-agent",
"version": "0.1.0",
"private": true,
"description": "Lokaler FEDEO Druck- und Scan-Agent für macOS, Linux und Raspberry Pi OS.",
"type": "module",
"main": "dist/main.js",
"bin": {
"fedeo-device-agent": "dist/main.js"
},
"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": {},
"devDependencies": {
"@types/node": "^24.3.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"engines": {
"node": ">=20"
}
}

View File

@@ -0,0 +1,3 @@
opencv-python-headless>=4.9
Pillow>=10.0
numpy>=1.26

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,219 @@
#!/usr/bin/env python3
import argparse
import math
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
def order_points(points):
rect = np.zeros((4, 2), dtype="float32")
point_sum = points.sum(axis=1)
point_diff = np.diff(points, axis=1)
rect[0] = points[np.argmin(point_sum)]
rect[2] = points[np.argmax(point_sum)]
rect[1] = points[np.argmin(point_diff)]
rect[3] = points[np.argmax(point_diff)]
return rect
def four_point_transform(image, points):
rect = order_points(points)
top_left, top_right, bottom_right, bottom_left = rect
width_a = np.linalg.norm(bottom_right - bottom_left)
width_b = np.linalg.norm(top_right - top_left)
max_width = int(max(width_a, width_b))
height_a = np.linalg.norm(top_right - bottom_right)
height_b = np.linalg.norm(top_left - bottom_left)
max_height = int(max(height_a, height_b))
destination = np.array([
[0, 0],
[max_width - 1, 0],
[max_width - 1, max_height - 1],
[0, max_height - 1],
], dtype="float32")
matrix = cv2.getPerspectiveTransform(rect, destination)
return cv2.warpPerspective(image, matrix, (max_width, max_height), borderValue=(255, 255, 255))
def rotate_bound(image, angle):
height, width = image.shape[:2]
center = (width / 2, height / 2)
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
cos = abs(matrix[0, 0])
sin = abs(matrix[0, 1])
new_width = int((height * sin) + (width * cos))
new_height = int((height * cos) + (width * sin))
matrix[0, 2] += (new_width / 2) - center[0]
matrix[1, 2] += (new_height / 2) - center[1]
return cv2.warpAffine(image, matrix, (new_width, new_height), borderValue=(255, 255, 255))
def deskew_by_text_angle(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
inverted = cv2.bitwise_not(gray)
threshold = cv2.threshold(inverted, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
coordinates = np.column_stack(np.where(threshold > 0))
if len(coordinates) < 500:
return image
angle = cv2.minAreaRect(coordinates)[-1]
if angle < -45:
angle = -(90 + angle)
else:
angle = -angle
if abs(angle) < 0.2 or abs(angle) > 8:
return image
return rotate_bound(image, angle)
def find_document_contour(image, profile):
ratio = image.shape[0] / 900.0
resized = cv2.resize(image, (int(image.shape[1] / ratio), 900))
gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(gray, 45, 140)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7))
edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contours = sorted(contours, key=cv2.contourArea, reverse=True)[:8]
min_area = resized.shape[0] * resized.shape[1] * (0.03 if profile == "receipt" else 0.12)
for contour in contours:
if cv2.contourArea(contour) < min_area:
continue
perimeter = cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, 0.025 * perimeter, True)
if len(approx) == 4:
return approx.reshape(4, 2) * ratio
return None
def trim_light_border(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
mask = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY_INV)[1]
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return image
contour = max(contours, key=cv2.contourArea)
if cv2.contourArea(contour) < image.shape[0] * image.shape[1] * 0.02:
return image
x, y, width, height = cv2.boundingRect(contour)
padding = max(12, int(min(width, height) * 0.025))
x = max(0, x - padding)
y = max(0, y - padding)
width = min(image.shape[1] - x, width + padding * 2)
height = min(image.shape[0] - y, height + padding * 2)
return image[y:y + height, x:x + width]
def enhance_receipt(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
gray = clahe.apply(gray)
gray = cv2.fastNlMeansDenoising(gray, None, 8, 7, 21)
gray = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX)
return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
def enhance_document(image):
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
l_channel, a_channel, b_channel = cv2.split(lab)
clahe = cv2.createCLAHE(clipLimit=1.6, tileGridSize=(8, 8))
l_channel = clahe.apply(l_channel)
return cv2.cvtColor(cv2.merge((l_channel, a_channel, b_channel)), cv2.COLOR_LAB2BGR)
def auto_rotate_profile(image, profile):
height, width = image.shape[:2]
if profile == "receipt" and width > height:
return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
return image
def postprocess(input_path, output_path, profile):
image = cv2.imread(str(input_path), cv2.IMREAD_COLOR)
if image is None:
raise RuntimeError(f"OpenCV konnte {input_path} nicht lesen")
contour = find_document_contour(image, profile)
if contour is not None:
processed = four_point_transform(image, contour.astype("float32"))
else:
processed = trim_light_border(image)
processed = deskew_by_text_angle(processed)
processed = trim_light_border(processed)
processed = auto_rotate_profile(processed, profile)
if profile == "receipt":
processed = enhance_receipt(processed)
elif profile != "raw":
processed = enhance_document(processed)
save_output(processed, output_path)
def save_output(image, output_path):
suffix = output_path.suffix.lower()
if suffix == ".pdf":
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(rgb)
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
pil_image.save(output_path, "PDF", resolution=300.0)
return
if suffix in {".jpg", ".jpeg"}:
cv2.imwrite(str(output_path), image, [cv2.IMWRITE_JPEG_QUALITY, 92])
return
if suffix == ".png":
cv2.imwrite(str(output_path), image, [cv2.IMWRITE_PNG_COMPRESSION, 3])
return
if suffix in {".tif", ".tiff"}:
cv2.imwrite(str(output_path), image)
return
raise RuntimeError(f"Nicht unterstütztes Ausgabeformat: {suffix}")
def main():
parser = argparse.ArgumentParser(description="FEDEO Scan-Nachbearbeitung mit OpenCV")
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--profile", default="document", choices=["document", "receipt", "raw"])
args = parser.parse_args()
postprocess(Path(args.input), Path(args.output), args.profile)
if __name__ == "__main__":
main()

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

@@ -0,0 +1,26 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
AGENT_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
VENV_DIR="${FEDEO_SCAN_POSTPROCESS_VENV:-$AGENT_DIR/.venv-opencv}"
PYTHON_BIN="${PYTHON:-python3}"
echo "FEDEO OpenCV-Umgebung wird vorbereitet"
echo "Agent: $AGENT_DIR"
echo "Venv: $VENV_DIR"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "Fehler: $PYTHON_BIN wurde nicht gefunden." >&2
exit 1
fi
"$PYTHON_BIN" -m venv "$VENV_DIR"
"$VENV_DIR/bin/python" -m pip install --upgrade pip
"$VENV_DIR/bin/python" -m pip install -r "$AGENT_DIR/requirements-opencv.txt"
"$VENV_DIR/bin/python" -c "import cv2, PIL, numpy; print('OpenCV OK')"
echo
echo "Fertig. Verwende in .env:"
echo "FEDEO_SCAN_POSTPROCESS=true"
echo "FEDEO_SCAN_POSTPROCESS_PYTHON=$VENV_DIR/bin/python"

View File

@@ -0,0 +1,71 @@
import { readFile, stat } from "node:fs/promises"
import { basename } from "node:path"
import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js"
export class FedeoApi {
constructor(private readonly config: AgentConfig) {}
private url(path: string) {
return `${this.config.fedeoUrl}${path}`
}
private headers(extra?: HeadersInit): HeadersInit {
return {
"X-Agent-Token": this.config.agentToken,
...extra,
}
}
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) {
const body = await response.text().catch(() => "")
throw new Error(`${init.method || "GET"} ${path} fehlgeschlagen: ${response.status} ${body}`)
}
return await response.json() as T
}
heartbeat(payload: AgentHeartbeat) {
return this.request<{ status: string; pendingScanJobs: number }>("/instance-agent/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
}
nextScanJob() {
return this.request<NextScanJobResponse>("/instance-agent/scan-jobs/next")
}
updateScanJobStatus(jobId: string, status: "running" | "failed" | "canceled", message?: string) {
return this.request(`/instance-agent/scan-jobs/${jobId}/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status, message }),
})
}
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), {
type: result.mimeType,
})
form.append("file", file)
return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, {
method: "POST",
body: form,
}, 120_000)
}
}

View File

@@ -0,0 +1,65 @@
import { spawn } from "node:child_process"
export type CommandResult = {
stdout: string
stderr: string
code: number
}
export const commandExists = (command: string) =>
new Promise<boolean>((resolve) => {
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))
})
export const runCommand = (
command: string,
args: string[],
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")
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", (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 ?? 1,
})
})
})

View File

@@ -0,0 +1,69 @@
import path from "node:path"
import os from "node:os"
import { existsSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { AgentConfig } from "./types.js"
import { loadDotEnv } from "./env.js"
const currentFile = fileURLToPath(import.meta.url)
const agentRoot = path.resolve(path.dirname(currentFile), "..")
const optional = (value: string | undefined) => {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
const numberFromEnv = (value: string | undefined, fallback: number) => {
if (!value) return fallback
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
const scanFormatFromEnv = (value: string | undefined): AgentConfig["scanFormat"] => {
if (value === "png" || value === "tiff" || value === "pdf") return value
return "pdf"
}
const booleanFromEnv = (value: string | undefined, fallback: boolean) => {
if (!value) return fallback
return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase())
}
const postprocessProfileFromEnv = (value: string | undefined): AgentConfig["postprocessProfile"] => {
if (value === "document" || value === "receipt" || value === "raw") return value
return "document"
}
const defaultPostprocessPython = () => {
const localVenvPython = path.join(agentRoot, ".venv-opencv", "bin", "python")
return existsSync(localVenvPython) ? localVenvPython : "python3"
}
export const loadConfig = (): AgentConfig => {
loadDotEnv(process.env.FEDEO_AGENT_ENV || ".env")
const fedeoUrl = optional(process.env.FEDEO_URL)
const agentToken = optional(process.env.FEDEO_AGENT_TOKEN)
if (!fedeoUrl) throw new Error("FEDEO_URL fehlt")
if (!agentToken) throw new Error("FEDEO_AGENT_TOKEN fehlt")
return {
fedeoUrl: fedeoUrl.replace(/\/+$/, ""),
agentToken,
pollSeconds: numberFromEnv(process.env.FEDEO_POLL_SECONDS, 5),
workDir: optional(process.env.FEDEO_WORK_DIR) || path.join(os.tmpdir(), "fedeo-device-agent"),
scannerName: optional(process.env.FEDEO_SCANNER_NAME),
printerName: optional(process.env.FEDEO_PRINTER_NAME),
scanFormat: scanFormatFromEnv(process.env.FEDEO_SCAN_FORMAT),
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(),
postprocessStrict: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_STRICT, false),
}
}

View File

@@ -0,0 +1,32 @@
import { readFileSync, existsSync } from "node:fs"
const parseEnvLine = (line: string) => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) return null
const separator = trimmed.indexOf("=")
if (separator === -1) return null
const key = trimmed.slice(0, separator).trim()
let value = trimmed.slice(separator + 1).trim()
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return { key, value }
}
export const loadDotEnv = (path = ".env") => {
if (!existsSync(path)) return
const content = readFileSync(path, "utf8")
for (const line of content.split(/\r?\n/)) {
const parsed = parseEnvLine(line)
if (!parsed) continue
if (process.env[parsed.key] === undefined) process.env[parsed.key] = parsed.value
}
}

View File

@@ -0,0 +1,30 @@
const timestamp = () => new Date().toISOString()
export const log = {
info(message: string, meta?: unknown) {
if (meta === undefined) {
console.log(`[${timestamp()}] INFO ${message}`)
return
}
console.log(`[${timestamp()}] INFO ${message}`, meta)
},
warn(message: string, meta?: unknown) {
if (meta === undefined) {
console.warn(`[${timestamp()}] WARN ${message}`)
return
}
console.warn(`[${timestamp()}] WARN ${message}`, meta)
},
error(message: string, meta?: unknown) {
if (meta === undefined) {
console.error(`[${timestamp()}] ERROR ${message}`)
return
}
console.error(`[${timestamp()}] ERROR ${message}`, meta)
},
}

View File

@@ -0,0 +1,132 @@
#!/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))
const stringifyError = (error: unknown) => {
if (error instanceof Error) return error.message
return String(error)
}
const main = async () => {
const config = loadConfig()
const api = new FedeoApi(config)
log.info("FEDEO Geräte-Agent startet", {
platform: process.platform,
workDir: config.workDir,
pollSeconds: config.pollSeconds,
})
while (true) {
try {
const scannerNames = await listScanners().catch((error) => {
log.warn("Scanner-Erkennung fehlgeschlagen", stringifyError(error))
return []
})
const printerNames = await listPrinters().catch(() => [])
const scanAvailable = await hasSane()
const heartbeatPayload: AgentHeartbeat = {
capabilities: {
scan: scanAvailable,
print: printerNames.length > 0,
platform: process.platform,
},
scannerNames,
printerNames,
debugInfo: {
hostname: os.hostname(),
release: os.release(),
arch: os.arch(),
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`)
}
const next = await api.nextScanJob()
if (!next.job) {
await sleep(config.pollSeconds * 1000)
continue
}
log.info("Scan-Auftrag wird ausgeführt", {
jobId: next.job.id,
tenantId: next.job.tenantId,
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 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)
log.error("Scan-Auftrag fehlgeschlagen", {
jobId: next.job.id,
message,
})
await api.updateScanJobStatus(next.job.id, "failed", message)
} finally {
clearInterval(heartbeatTimer)
}
} catch (error) {
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))
await sleep(config.pollSeconds * 1000)
}
}
}
main().catch((error) => {
log.error("Agent konnte nicht gestartet werden", stringifyError(error))
process.exit(1)
})

View File

@@ -0,0 +1,15 @@
import { commandExists, runCommand } from "../commands.js"
export const hasCups = () => commandExists("lpstat")
export const listPrinters = async () => {
if (!await hasCups()) return []
const result = await runCommand("lpstat", ["-p"], { timeoutMs: 10_000 })
if (result.code !== 0) return []
return result.stdout
.split(/\r?\n/)
.map((line) => line.match(/^printer\s+(\S+)/)?.[1])
.filter((printer): printer is string => Boolean(printer))
}

View File

@@ -0,0 +1,45 @@
import path from "node:path"
import { fileURLToPath } from "node:url"
import { AgentConfig, ScanResult } from "../types.js"
import { runCommand } from "../commands.js"
const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const documentScript = path.join(agentRoot, "scripts/scan_document.py")
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.")
}
export const createScanDocument = async (
config: AgentConfig,
pages: string[],
outputPath: string,
resolution: number,
postprocess: boolean,
profile: AgentConfig["postprocessProfile"]
): Promise<ScanResult> => {
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, args, {
timeoutMs: config.scanTimeoutSeconds * 1000,
})
if (result.code !== 0) throw new Error(result.stderr || "Scan-Datei konnte nicht erstellt werden")
const details = JSON.parse(result.stdout) as { pageCount: number; warnings: string[] }
return {
path: outputPath,
filename: path.basename(outputPath),
mimeType: outputPath.endsWith(".pdf") ? "application/pdf" : outputPath.endsWith(".png") ? "image/png" : "image/tiff",
...details,
}
}

View File

@@ -0,0 +1,118 @@
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 { checkScanRuntime, createScanDocument } from "./postprocess.js"
const stringSetting = (settings: Record<string, unknown>, key: string) => {
const value = settings[key]
return typeof value === "string" && value.trim() ? value.trim() : undefined
}
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
}
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().match(/^device `([^']+)'/)?.[1])
.filter((device): device is string => Boolean(device))
}
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") || 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 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)
// 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 {
// 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,
})
// 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.")
}
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

@@ -0,0 +1,51 @@
export type AgentConfig = {
fedeoUrl: string
agentToken: string
pollSeconds: number
workDir: string
scannerName?: string
printerName?: string
scanFormat: "pdf" | "png" | "tiff"
scanResolution: number
scanMode: string
scanSource?: string
scanTimeoutSeconds: number
scanPostprocess: boolean
postprocessProfile: "document" | "receipt" | "raw"
postprocessPython: string
postprocessStrict: boolean
}
export type AgentHeartbeat = {
capabilities: {
scan: boolean
print: boolean
platform: NodeJS.Platform
}
scannerNames: string[]
printerNames: string[]
debugInfo: Record<string, unknown>
}
export type ScanJob = {
id: string
tenantId: number
agentId: string
status: string
scannerName?: string | null
requestedFilename?: string | null
settings?: Record<string, unknown>
target?: Record<string, unknown>
}
export type NextScanJobResponse = {
job: ScanJob | null
}
export type ScanResult = {
path: string
filename: string
mimeType: string
pageCount: number
warnings: string[]
}

View File

@@ -0,0 +1,15 @@
[Unit]
Description=FEDEO Geräte-Agent
After=network-online.target
Wants=network-online.target
[Service]
EnvironmentFile=/etc/fedeo-device-agent/config.env
WorkingDirectory=/opt/fedeo-device-agent
ExecStart=/usr/bin/node /opt/fedeo-device-agent/dist/main.js
Restart=always
RestartSec=5
User=fedeo-agent
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.fedeo.device-agent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/opt/fedeo-device-agent/dist/main.js</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>FEDEO_AGENT_ENV</key>
<string>/opt/fedeo-device-agent/.env</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/fedeo-device-agent.log</string>
<key>StandardErrorPath</key>
<string>/tmp/fedeo-device-agent.err.log</string>
</dict>
</plist>

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

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

7
backend/.dockerignore Normal file
View File

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

1
backend/.gitignore vendored
View File

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

View File

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

View File

@@ -1,20 +1,33 @@
FROM node:20-alpine
FROM node:22-bookworm-slim
WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
poppler-utils \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
# Package-Dateien
COPY package*.json ./
# Dev + Prod Dependencies (für TS-Build nötig)
RUN npm install
# Dev + Prod Dependencies (für TS-Build nötig).
# 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
# Start der App
CMD ["node", "dist/src/index.js"]
# Migrationen ausführen und App starten
CMD ["sh", "./docker-entrypoint.sh"]

View File

@@ -1,13 +1,33 @@
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
// src/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
import {secrets} from "../src/utils/secrets";
import * as schema from "./schema"
console.log("[DB INIT] 1. Suche Connection String...");
const fallbackConnectionString = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
// Checken woher die URL kommt
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
if (process.env.DATABASE_URL) {
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
} else if (secrets.DATABASE_URL) {
console.log("[DB INIT] -> Gefunden in secrets.DATABASE_URL");
} else if (connectionString) {
console.log("[DB INIT] -> Nutze Fallback aus dem Projekt");
} else {
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
}
export const pool = new Pool({
connectionString: secrets.DATABASE_URL,
max: 10, // je nach Last
})
connectionString,
max: 10,
});
export const db = drizzle(pool , {schema})
// TEST: Ist die DB wirklich da?
pool.query('SELECT NOW()')
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
export const db = drizzle(pool, { schema });

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
CREATE TABLE "m2m_api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"created_by" uuid,
"name" text NOT NULL,
"key_prefix" text NOT NULL,
"key_hash" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"last_used_at" timestamp with time zone,
"expires_at" timestamp with time zone,
CONSTRAINT "m2m_api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
CREATE TABLE "serialtypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "serialtypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"intervall" text,
"icon" text,
"tenant" bigint NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "serial_executions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant" bigint NOT NULL,
"execution_date" timestamp NOT NULL,
"status" text DEFAULT 'draft',
"created_by" text,
"created_at" timestamp DEFAULT now(),
"summary" text
);
--> statement-breakpoint
CREATE TABLE "public_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"token" text NOT NULL,
"tenant" integer NOT NULL,
"default_profile" uuid,
"is_protected" boolean DEFAULT false NOT NULL,
"pin_hash" text,
"config" jsonb DEFAULT '{}'::jsonb,
"name" text NOT NULL,
"description" text,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "public_links_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "wiki_pages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"parent_id" uuid,
"title" text NOT NULL,
"content" jsonb,
"is_folder" boolean DEFAULT false NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"entity_type" text,
"entity_id" bigint,
"entity_uuid" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "projects" ALTER COLUMN "active_phase" SET DEFAULT 'Erstkontakt';--> statement-breakpoint
ALTER TABLE "createddocuments" ADD COLUMN "serialexecution" uuid;--> statement-breakpoint
ALTER TABLE "devices" ADD COLUMN "last_seen" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "devices" ADD COLUMN "last_debug_info" jsonb;--> statement-breakpoint
ALTER TABLE "files" ADD COLUMN "size" bigint;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD COLUMN "related_event_id" uuid;--> statement-breakpoint
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "m2m_api_keys" ADD CONSTRAINT "m2m_api_keys_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_related_event_id_staff_time_events_id_fk" FOREIGN KEY ("related_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "serial_executions" ADD CONSTRAINT "serial_executions_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "public_links" ADD CONSTRAINT "public_links_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "public_links" ADD CONSTRAINT "public_links_default_profile_auth_profiles_id_fk" FOREIGN KEY ("default_profile") REFERENCES "public"."auth_profiles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_parent_id_wiki_pages_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "wiki_pages_tenant_idx" ON "wiki_pages" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_parent_idx" ON "wiki_pages" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_entity_int_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_entity_uuid_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_uuid");--> statement-breakpoint
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_serialexecution_serial_executions_id_fk" FOREIGN KEY ("serialexecution") REFERENCES "public"."serial_executions"("id") ON DELETE no action ON UPDATE no action;

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;
--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;

View File

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

View File

@@ -0,0 +1,16 @@
CREATE TABLE "entitybankaccounts" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"iban_encrypted" jsonb NOT NULL,
"bic_encrypted" jsonb NOT NULL,
"bank_name_encrypted" jsonb NOT NULL,
"description" text,
"updated_at" timestamp with time zone,
"updated_by" uuid,
"archived" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,73 @@
CREATE TABLE "customerspaces" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"spaceNumber" text NOT NULL,
"parentSpace" bigint,
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerinventoryitems" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"customerspace" bigint,
"customerInventoryId" text NOT NULL,
"serialNumber" text,
"quantity" bigint DEFAULT 0 NOT NULL,
"manufacturer" text,
"manufacturerNumber" text,
"purchaseDate" date,
"purchasePrice" double precision DEFAULT 0,
"currentValue" double precision,
"product" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
CREATE UNIQUE INDEX "customerinventoryitems_tenant_customerInventoryId_idx" ON "customerinventoryitems" USING btree ("tenant","customerInventoryId");
--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;
--> statement-breakpoint
UPDATE "tenants"
SET "numberRanges" = COALESCE("numberRanges", '{}'::jsonb) || jsonb_build_object(
'customerspaces', COALESCE("numberRanges"->'customerspaces', '{"prefix":"KLP-","suffix":"","nextNumber":1000}'::jsonb),
'customerinventoryitems', COALESCE("numberRanges"->'customerinventoryitems', '{"prefix":"KIA-","suffix":"","nextNumber":1000}'::jsonb)
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE "customerinventoryitems" ADD COLUMN "vendor" bigint;
--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,20 @@
CREATE TABLE "memberrelations" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"type" text NOT NULL,
"billingInterval" text NOT NULL,
"billingAmount" double precision DEFAULT 0 NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;
--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,33 @@
ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "memberrelation" bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'customers_memberrelation_memberrelations_id_fk'
) THEN
ALTER TABLE "customers"
ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk"
FOREIGN KEY ("memberrelation")
REFERENCES "public"."memberrelations"("id")
ON DELETE no action
ON UPDATE no action;
END IF;
END $$;
UPDATE "customers"
SET "memberrelation" = ("infoData"->>'memberrelation')::bigint
WHERE
"memberrelation" IS NULL
AND "type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation'
AND ("infoData"->>'memberrelation') ~ '^[0-9]+$';
UPDATE "customers"
SET "infoData" = COALESCE("infoData", '{}'::jsonb) - 'memberrelation'
WHERE
"type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation';

View File

@@ -0,0 +1 @@
-- Absichtlich leer: Die Objekte aus dieser generierten Migration existieren bereits in früheren Migrationen.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
CREATE TABLE "branches" (
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"number" text,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "costcentres" ADD COLUMN "branch" bigint;
--> statement-breakpoint
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD COLUMN "branch_id" bigint;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD CONSTRAINT "auth_profiles_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
CREATE TABLE "auth_profile_branches" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"profile_id" uuid NOT NULL,
"branch_id" bigint NOT NULL,
"created_by" uuid,
CONSTRAINT "auth_profile_branches_profile_id_branch_id_pk" PRIMARY KEY("profile_id","branch_id")
);
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "statementallocations"
ADD COLUMN "booking_mode" text DEFAULT 'expense' NOT NULL,
ADD COLUMN "depreciation_months" integer,
ADD COLUMN "depreciation_start_date" text,
ADD COLUMN "depreciation_label" text,
ADD COLUMN "depreciation_group" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "statementallocations"
ADD COLUMN "depreciation_method" text,
ADD COLUMN "residual_value" double precision;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "products"
ADD COLUMN "supplierLink" text;

View File

@@ -0,0 +1,31 @@
CREATE TABLE "teams" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "teams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"branch" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "auth_profile_teams" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"profile_id" uuid NOT NULL,
"team_id" bigint NOT NULL,
"created_by" uuid,
CONSTRAINT "auth_profile_teams_profile_id_team_id_pk" PRIMARY KEY("profile_id","team_id")
);
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

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

View File

@@ -0,0 +1,10 @@
ALTER TABLE "statementallocations" ALTER COLUMN "bs_id" DROP NOT NULL;
ALTER TABLE "statementallocations" ADD COLUMN "manual_booking_date" text;
ALTER TABLE "statementallocations" ADD COLUMN "contra_account" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_ownaccount" uuid;
ALTER TABLE "statementallocations" ADD COLUMN "contra_customer" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_vendor" bigint;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_account_accounts_id_fk" FOREIGN KEY ("contra_account") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_ownaccount_ownaccounts_id_fk" FOREIGN KEY ("contra_ownaccount") REFERENCES "public"."ownaccounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_customer_customers_id_fk" FOREIGN KEY ("contra_customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_vendor_vendors_id_fk" FOREIGN KEY ("contra_vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "datev_tax_key" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "manual_invoice_side" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid;
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,8 @@
ALTER TABLE "events" ADD COLUMN "color" text;
UPDATE "events" AS e
SET "color" = COALESCE(t."calendarConfig"->'quickEntry'->>'color', '#2563eb')
FROM "tenants" AS t
WHERE e."tenant" = t."id"
AND e."quick" = true
AND e."color" IS NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN IF NOT EXISTS "availability_note" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "historyitems" ADD COLUMN "contract" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_contract_contracts_id_fk" FOREIGN KEY ("contract") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;

View File

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

View File

@@ -0,0 +1,53 @@
CREATE TABLE "outgoingsepamandates" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "outgoingsepamandates_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"bankaccount" bigint NOT NULL,
"reference" text NOT NULL,
"status" text DEFAULT 'Entwurf' NOT NULL,
"mandate_type" text DEFAULT 'CORE' NOT NULL,
"sequence_type" text DEFAULT 'RCUR' NOT NULL,
"signed_at" timestamp with time zone,
"valid_from" timestamp with time zone,
"valid_until" timestamp with time zone,
"default_mandate" boolean DEFAULT false NOT NULL,
"notes" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_bankaccount_entitybankaccounts_id_fk" FOREIGN KEY ("bankaccount") REFERENCES "public"."entitybankaccounts"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "outgoingsepamandate" bigint;
--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "createddocuments" ADD COLUMN "outgoingsepamandate" bigint;
--> statement-breakpoint
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "outgoingsepamandate" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"costEstimates":{"prefix":"KS-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"deliveryNotes":{"prefix":"LS-","suffix":"","nextNumber":1000},"packingSlips":{"prefix":"PS-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000},"outgoingsepamandates":{"prefix":"SEPA-","suffix":"","nextNumber":1000}}'::jsonb;
--> statement-breakpoint
UPDATE "tenants"
SET "numberRanges" = COALESCE("numberRanges", '{}'::jsonb) || jsonb_build_object(
'outgoingsepamandates',
COALESCE("numberRanges"->'outgoingsepamandates', '{"prefix":"SEPA-","suffix":"","nextNumber":1000}'::jsonb)
);
--> statement-breakpoint
UPDATE "tenants"
SET "features" = COALESCE("features", '{}'::jsonb) || jsonb_build_object(
'outgoingsepamandates',
COALESCE("features"->'outgoingsepamandates', 'true'::jsonb)
);

View File

@@ -0,0 +1,40 @@
CREATE TABLE "communication_rooms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"key" text NOT NULL,
"name" text NOT NULL,
"topic" text,
"type" text DEFAULT 'room' NOT NULL,
"entity_type" text,
"entity_id" bigint,
"entity_uuid" uuid,
"archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
CREATE UNIQUE INDEX "communication_rooms_tenant_key_idx"
ON "communication_rooms" USING btree ("tenant_id", "key");
CREATE INDEX "communication_rooms_tenant_idx"
ON "communication_rooms" USING btree ("tenant_id");
CREATE INDEX "communication_rooms_entity_idx"
ON "communication_rooms" USING btree ("tenant_id", "entity_type", "entity_id", "entity_uuid");

View File

@@ -0,0 +1,5 @@
ALTER TABLE "events" ADD COLUMN "state" text DEFAULT 'Final' NOT NULL;
UPDATE "events"
SET "state" = 'Final'
WHERE "state" IS NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "events" ADD COLUMN "repeatInterval" text DEFAULT 'Keine Wiederholung' NOT NULL;

View File

@@ -0,0 +1,46 @@
CREATE TABLE "notification_push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"user_agent" text,
"device_label" text,
"meta" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"disabled_at" timestamp with time zone,
CONSTRAINT "notification_push_subscriptions_endpoint_key" UNIQUE("endpoint")
);
ALTER TABLE "notification_push_subscriptions"
ADD CONSTRAINT "notification_push_subscriptions_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE cascade;
ALTER TABLE "notification_push_subscriptions"
ADD CONSTRAINT "notification_push_subscriptions_user_id_auth_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id")
ON DELETE cascade ON UPDATE cascade;
INSERT INTO "notifications_event_types" (
"event_key",
"display_name",
"description",
"category",
"severity",
"allowed_channels"
) VALUES
('system.test_push', 'Test-Push', 'Testet Desktop-Benachrichtigungen für den angemeldeten Nutzer.', 'system', 'info', '["inapp", "push"]'::jsonb),
('communication.message.new', 'Neue Chatnachricht', 'Benachrichtigt über relevante neue Chatnachrichten.', 'communication', 'info', '["inapp", "push"]'::jsonb),
('communication.call.started', 'Besprechung gestartet', 'Benachrichtigt Raumteilnehmer über gestartete Audio- oder Videoanrufe.', 'communication', 'info', '["inapp", "push"]'::jsonb),
('communication.call.missed', 'Verpasster Anruf', 'Benachrichtigt über verpasste Besprechungen.', 'communication', 'warning', '["inapp", "push"]'::jsonb),
('communication.room.invited', 'Raumeinladung', 'Benachrichtigt über Einladungen in Kommunikationsräume.', 'communication', 'info', '["inapp", "push"]'::jsonb)
ON CONFLICT ("event_key") DO UPDATE SET
"display_name" = EXCLUDED."display_name",
"description" = EXCLUDED."description",
"category" = EXCLUDED."category",
"severity" = EXCLUDED."severity",
"allowed_channels" = EXCLUDED."allowed_channels",
"is_active" = true;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "filetags" ADD COLUMN "isSystemUsed" boolean DEFAULT false NOT NULL;
UPDATE "filetags"
SET "isSystemUsed" = true
WHERE COALESCE("createddocumenttype", '') <> ''
OR COALESCE("incomingDocumentType", '') <> '';

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN "calendar_subscription_token" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN IF NOT EXISTS "availability_note" text;

View File

@@ -0,0 +1,55 @@
CREATE TABLE IF NOT EXISTS "communication_rooms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"key" text NOT NULL,
"name" text NOT NULL,
"topic" text,
"type" text DEFAULT 'room' NOT NULL,
"entity_type" text,
"entity_id" bigint,
"entity_uuid" uuid,
"archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'communication_rooms_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "communication_rooms"
ADD CONSTRAINT "communication_rooms_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "communication_rooms_tenant_key_idx"
ON "communication_rooms" USING btree ("tenant_id", "key");
CREATE INDEX IF NOT EXISTS "communication_rooms_tenant_idx"
ON "communication_rooms" USING btree ("tenant_id");
CREATE INDEX IF NOT EXISTS "communication_rooms_entity_idx"
ON "communication_rooms" USING btree ("tenant_id", "entity_type", "entity_id", "entity_uuid");

View File

@@ -0,0 +1,57 @@
CREATE TABLE IF NOT EXISTS "telephony_calls" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"direction" text NOT NULL,
"status" text DEFAULT 'ringing' NOT NULL,
"local_extension" text,
"remote_number" text,
"remote_display_name" text,
"sip_call_id" text,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"answered_at" timestamp with time zone,
"ended_at" timestamp with time zone,
"duration_seconds" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_calls_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "telephony_calls"
ADD CONSTRAINT "telephony_calls_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_calls_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_calls"
ADD CONSTRAINT "telephony_calls_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_calls_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_calls"
ADD CONSTRAINT "telephony_calls_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "telephony_calls_tenant_started_idx"
ON "telephony_calls" USING btree ("tenant_id", "started_at");
CREATE INDEX IF NOT EXISTS "telephony_calls_created_by_idx"
ON "telephony_calls" USING btree ("tenant_id", "created_by");
CREATE INDEX IF NOT EXISTS "telephony_calls_sip_call_idx"
ON "telephony_calls" USING btree ("tenant_id", "sip_call_id");

View File

@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS "telephony_trunks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"provider" text DEFAULT 'telekom' NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"registrar" text DEFAULT 'tel.t-online.de' NOT NULL,
"sip_user" text,
"auth_user" text,
"password" text,
"caller_id" text,
"inbound_extension" text DEFAULT '1001' NOT NULL,
"outbound_prefix" text DEFAULT '0' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "telephony_trunks_tenant_provider_idx"
ON "telephony_trunks" USING btree ("tenant_id", "provider");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "telephony_trunks" ADD COLUMN IF NOT EXISTS "external_signaling_address" text;
ALTER TABLE "telephony_trunks" ADD COLUMN IF NOT EXISTS "external_media_address" text;
ALTER TABLE "telephony_trunks" ADD COLUMN IF NOT EXISTS "local_networks" text;

View File

@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS "telephony_extensions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"target_type" text NOT NULL,
"target_user_id" uuid,
"target_team_id" bigint,
"target_branch_id" bigint,
"extension" text NOT NULL,
"display_name" text,
"sip_username" text,
"sip_password" text,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
ALTER TABLE "telephony_trunks"
ADD COLUMN IF NOT EXISTS "default_route_extension_id" uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_user_id_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_user_id_auth_users_id_fk"
FOREIGN KEY ("target_user_id") REFERENCES "public"."auth_users"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_team_id_teams_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_team_id_teams_id_fk"
FOREIGN KEY ("target_team_id") REFERENCES "public"."teams"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_branch_id_branches_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_branch_id_branches_id_fk"
FOREIGN KEY ("target_branch_id") REFERENCES "public"."branches"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_default_route_extension_id_telephony_extensions_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_default_route_extension_id_telephony_extensions_id_fk"
FOREIGN KEY ("default_route_extension_id") REFERENCES "public"."telephony_extensions"("id")
ON DELETE set null ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "telephony_extensions_tenant_extension_idx"
ON "telephony_extensions" USING btree ("tenant_id", "extension");
CREATE INDEX IF NOT EXISTS "telephony_extensions_tenant_target_idx"
ON "telephony_extensions" USING btree ("tenant_id", "target_type");

View File

@@ -0,0 +1,19 @@
CREATE TABLE "notification_mobile_push_devices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"local_device_id" text NOT NULL,
"central_device_id" text NOT NULL,
"platform" text NOT NULL,
"provider_token_preview" text,
"device_label" text,
"meta" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"disabled_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "notification_mobile_push_devices" ADD CONSTRAINT "notification_mobile_push_devices_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "notification_mobile_push_devices" ADD CONSTRAINT "notification_mobile_push_devices_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "notification_mobile_push_devices_user_device_key" ON "notification_mobile_push_devices" USING btree ("tenant_id","user_id","local_device_id");--> statement-breakpoint
CREATE UNIQUE INDEX "notification_mobile_push_devices_central_device_key" ON "notification_mobile_push_devices" USING btree ("central_device_id");

View File

@@ -0,0 +1,106 @@
CREATE TABLE "email_mailboxes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"account_id" uuid NOT NULL,
"path" text NOT NULL,
"delimiter" text,
"name" text NOT NULL,
"special_use" text,
"flags" jsonb,
"exists" integer DEFAULT 0 NOT NULL,
"unseen" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "email_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"account_id" uuid NOT NULL,
"mailbox_id" uuid NOT NULL,
"mailbox_path" text NOT NULL,
"uid" bigint NOT NULL,
"email_id" text,
"message_id" text,
"in_reply_to" text,
"thread_id" text,
"subject" text,
"from" jsonb,
"to" jsonb,
"cc" jsonb,
"bcc" jsonb,
"reply_to" jsonb,
"preview" text,
"flags" jsonb,
"seen" boolean DEFAULT false NOT NULL,
"flagged" boolean DEFAULT false NOT NULL,
"has_attachments" boolean DEFAULT false NOT NULL,
"size" bigint,
"sent_at" timestamp with time zone,
"received_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "email_message_bodies" (
"message_id" uuid PRIMARY KEY NOT NULL,
"text" text,
"html" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "email_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"message_id" uuid NOT NULL,
"filename" text,
"content_type" text,
"content_id" text,
"disposition" text,
"size" bigint,
"checksum" text,
"storage_key" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "email_sync_state" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"account_id" uuid NOT NULL,
"mailbox_id" uuid NOT NULL,
"mailbox_path" text NOT NULL,
"uid_validity" bigint,
"highest_uid" bigint DEFAULT 0 NOT NULL,
"mod_seq" text,
"last_synced_at" timestamp with time zone,
"sync_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "email_mailboxes" ADD CONSTRAINT "email_mailboxes_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_mailboxes" ADD CONSTRAINT "email_mailboxes_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_mailboxes" ADD CONSTRAINT "email_mailboxes_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_mailbox_id_email_mailboxes_id_fk" FOREIGN KEY ("mailbox_id") REFERENCES "public"."email_mailboxes"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_message_bodies" ADD CONSTRAINT "email_message_bodies_message_id_email_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_attachments" ADD CONSTRAINT "email_attachments_message_id_email_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_account_id_user_credentials_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."user_credentials"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "email_sync_state" ADD CONSTRAINT "email_sync_state_mailbox_id_email_mailboxes_id_fk" FOREIGN KEY ("mailbox_id") REFERENCES "public"."email_mailboxes"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "email_mailboxes_account_path_key" ON "email_mailboxes" USING btree ("account_id","path");--> statement-breakpoint
CREATE INDEX "email_mailboxes_tenant_account_idx" ON "email_mailboxes" USING btree ("tenant_id","account_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_messages_mailbox_uid_key" ON "email_messages" USING btree ("mailbox_id","uid");--> statement-breakpoint
CREATE INDEX "email_messages_account_mailbox_idx" ON "email_messages" USING btree ("account_id","mailbox_path");--> statement-breakpoint
CREATE INDEX "email_messages_received_idx" ON "email_messages" USING btree ("received_at");--> statement-breakpoint
CREATE INDEX "email_messages_message_id_idx" ON "email_messages" USING btree ("message_id");--> statement-breakpoint
CREATE INDEX "email_messages_thread_idx" ON "email_messages" USING btree ("thread_id");--> statement-breakpoint
CREATE INDEX "email_attachments_message_idx" ON "email_attachments" USING btree ("message_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_sync_state_mailbox_key" ON "email_sync_state" USING btree ("account_id","mailbox_path");--> statement-breakpoint
CREATE INDEX "email_sync_state_tenant_account_idx" ON "email_sync_state" USING btree ("tenant_id","account_id");

View File

@@ -0,0 +1,7 @@
ALTER TABLE "createddocuments" ADD COLUMN "costcentre" uuid;
--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "costcentre" uuid;
--> statement-breakpoint
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_costcentre_costcentres_id_fk" FOREIGN KEY ("costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "services" ADD CONSTRAINT "services_costcentre_costcentres_id_fk" FOREIGN KEY ("costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,43 @@
CREATE TABLE "instance_agents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"token_prefix" text NOT NULL,
"token_hash" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL,
"scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
"printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
"last_seen_at" timestamp with time zone,
"last_debug_info" jsonb,
CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "instance_agent_scan_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant_id" bigint NOT NULL,
"agent_id" uuid NOT NULL,
"requested_by" uuid,
"status" text DEFAULT 'pending' NOT NULL,
"scanner_name" text,
"requested_filename" text,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"target" jsonb DEFAULT '{}'::jsonb NOT NULL,
"agent_message" text,
"attempts" integer DEFAULT 0 NOT NULL,
"claimed_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"file_id" uuid,
CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade
);
--> statement-breakpoint
CREATE INDEX "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at");
--> statement-breakpoint
CREATE INDEX "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "instance_agents" ADD COLUMN "preferred_scanner_name" text;
--> statement-breakpoint
ALTER TABLE "instance_agents" ADD COLUMN "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb;

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