Compare commits

...

43 Commits

Author SHA1 Message Date
80b2b1d097 KI-AGENT: Profil-Verfügbarkeitsmigration im Journal ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 1m22s
Build and Push Docker Images / build-frontend (push) Successful in 2m27s
Build and Push Docker Images / build-docs (push) Successful in 59s
2026-05-18 20:15:58 +02:00
f5755993b5 KI-AGENT: Sharp im Backend-Container für Linux installieren 2026-05-18 20:12:19 +02:00
0f56102030 KI-AGENT: Wiederholte Sammel-Migration leeren 2026-05-18 20:10:22 +02:00
60d846baa9 KI-AGENT: Doppelte Vertragstypen-Migration bereinigen 2026-05-18 20:08:24 +02:00
a28b910d4d KI-AGENT: Doppelte Zeiterfassungs-Migration bereinigen 2026-05-18 20:02:29 +02:00
4aeefb2b83 KI-AGENT: Zentrale Benachrichtigungsengine mit Desktop Push umsetzen 2026-05-18 19:51:08 +02:00
24c09d7891 KI-AGENT: Kamera und Bildschirmfreigabe getrennt anzeigen 2026-05-18 19:28:53 +02:00
77eabe7e18 KI-AGENT: FEDEO-Call-Erlebnis ausbauen 2026-05-18 19:24:12 +02:00
e4073e01ad KI-AGENT: Lokale LiveKit-Verbindung für FEDEO-Calls korrigieren 2026-05-18 19:18:43 +02:00
248da3412c KI-AGENT: LiveKit-Calls nativ in FEDEO integrieren 2026-05-18 19:15:36 +02:00
c93ea4284d KI-AGENT: Matrix-Einbettung stabilisieren 2026-05-18 18:56:37 +02:00
7c68ce61f2 KI-AGENT: Matrix-Anrufe im Chat vorbereiten 2026-05-18 18:38:21 +02:00
f6dd37b458 KI-AGENT: Matrix-Teilnehmer synchronisieren 2026-05-18 18:33:17 +02:00
bb54a8779e KI-AGENT: Chaträume im Frontend verwalten 2026-05-18 18:23:14 +02:00
6e14f48770 KI-AGENT: Matrix-Räume persistent verwalten 2026-05-18 18:19:23 +02:00
4d24e3a657 KI-AGENT: Erstzugriff und Mandanten-Grunddaten für Selfhosting ergänzen 2026-05-18 18:17:23 +02:00
f33ccf730a KI-AGENT: Matrix-Raum-API verallgemeinern 2026-05-18 18:12:26 +02:00
8824b1c9c8 KI-AGENT: Selfhosting für Secrets, Compose und Migrationen vorbereiten 2026-05-18 18:06:03 +02:00
571c24f250 KI-AGENT: Chat in eigene Kommunikationsseite auslagern 2026-05-18 18:01:54 +02:00
b03af21e97 KI-AGENT: Matrix-Login-Rate-Limits vermeiden 2026-05-18 17:55:41 +02:00
b1e102ca5d KI-AGENT: Matrix-Chat Rate-Limits reduzieren 2026-05-18 17:49:31 +02:00
8b40be7909 KI-AGENT: Matrix-Chat live aktualisieren 2026-05-18 17:45:56 +02:00
655459a46b KI-AGENT: Nativen Matrix-Chat in FEDEO starten 2026-05-18 17:41:31 +02:00
5fca7792a2 KI-AGENT: Scrollen auf Kommunikationsseite ermöglichen 2026-05-18 17:35:10 +02:00
30b6ffcc20 KI-AGENT: Matrix-Chat in FEDEO einbetten 2026-05-18 17:29:34 +02:00
7f66f66cfa KI-AGENT: Matrix-Räume in FEDEO provisionieren 2026-05-18 17:24:46 +02:00
d0de3cb92e KI-AGENT: Matrix-Mandanten-Space provisionieren 2026-05-18 17:11:52 +02:00
c893574cb1 KI-AGENT: Matrix-Kontoerstellung nutzbarer machen 2026-05-18 16:59:35 +02:00
eb2dd03ef9 KI-AGENT: Matrix-Kommunikation im Frontend anbinden 2026-05-18 15:42:10 +02:00
b322d0c173 KI-AGENT: Erste Matrix-Backendintegration ergänzen 2026-05-18 15:37:12 +02:00
54ae136f0d KI-AGENT: LiveKit-Start im Matrix-Stack korrigieren 2026-05-18 15:33:34 +02:00
00e1e88dd9 KI-AGENT: Lokalen Matrix-Entwicklungsstack ergänzen 2026-05-18 15:24:43 +02:00
3984e218db KI-AGENT: Matrix-Stack in Docker Compose vorbereiten 2026-05-18 14:58:27 +02:00
d9c3c8d07c KI-AGENT: Matrix-Kommunikationslösung entwerfen 2026-05-18 14:38:41 +02:00
c6a0d59c29 SEPA-Exportdatei aus Mandaten erzeugen #182 2026-05-15 18:36:48 +02:00
9592e2b062 SEPA-Mandate in Verträge und Ausgangsrechnungen einbauen #183 2026-05-15 18:13:29 +02:00
d522cbb49d Bankverbindungs-Buttons bei SEPA-Mandaten ergänzen #183 2026-05-15 18:06:43 +02:00
8d7bc2e97c SEPA-Mandatsauswahlen als Wörterbücher pflegen #183 2026-05-15 18:00:57 +02:00
44017a768b Ausgehende SEPA-Mandate einführen #183 2026-05-15 17:47:11 +02:00
683d073b6e KI-AGENT: MCP-Belegpositionen für Angebote normalisieren 2026-05-15 17:11:31 +02:00
cb939f2197 DATEV-Export Datumsformat korrigieren #181
KI-AGENT: Normalisiert Export-Zeiträume und Belegdatumsfelder vor der DATEV-Ausgabe auf Europe/Berlin, damit per UI gewählte Daten nicht durch UTC-Verschiebung auf den Vortag fallen.
2026-05-15 16:54:34 +02:00
0e71899c57 DATEV-BU-Schlüssel für Ausgangsbelege entfernen #179 2026-05-13 09:52:22 +02:00
6b82f2b629 DATEV-Erlöskonten für Ausgangsbelege aufteilen #179 2026-05-13 09:48:52 +02:00
64 changed files with 18130 additions and 452 deletions

103
.env.example Normal file
View File

@@ -0,0 +1,103 @@
# FEDEO Selfhosting
DOMAIN=app.example.com
CONTACT_EMAIL=admin@example.com
DB_NAME=fedeo
DB_USER=fedeo
DB_PASSWORD=change-this-db-password
DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo
MINIO_ROOT_USER=fedeo-minio
MINIO_ROOT_PASSWORD=change-this-minio-password
MINIO_BUCKET=fedeo
HOST=0.0.0.0
PORT=3100
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
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
# 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
# FEDEO Matrix-Kommunikation
#
# Diese Werte werden von docker-compose.yml gelesen, wenn das Profil "matrix"
# genutzt wird. Für produktive Systeme müssen alle Geheimnisse ersetzt werden.
MATRIX_SERVER_NAME=fedeo.de
MATRIX_HOMESERVER_HOST=matrix.fedeo.de
MATRIX_RTC_HOST=call.fedeo.de
MATRIX_TURN_HOST=turn.fedeo.de
MATRIX_POSTGRES_DB=synapse
MATRIX_POSTGRES_USER=synapse
MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password
MATRIX_TURN_SHARED_SECRET=change-this-turn-secret
LIVEKIT_KEY=fedeo-livekit
LIVEKIT_SECRET=change-this-livekit-secret-please-replace
# Lokale Matrix-Entwicklung
MATRIX_DEV_SYNAPSE_PORT=8008
MATRIX_DEV_ELEMENT_PORT=8080
MATRIX_DEV_RTC_JWT_PORT=8081
MATRIX_DEV_LIVEKIT_PORT=7880
MATRIX_DEV_LIVEKIT_TCP_PORT=7881
MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000
MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100
MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1
MATRIX_DEV_TURN_PORT=3478
MATRIX_DEV_TURN_MIN_PORT=49160
MATRIX_DEV_TURN_MAX_PORT=49200
# Backend-Integration gegen den lokalen Matrix-Stack
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_RTC_JWT_URL=http://localhost:8081
MATRIX_LIVEKIT_URL=ws://localhost:7880
MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml
MATRIX_SERVICE_USER_LOCALPART=fedeo_service
NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
# Lokale Runtime-Daten und generierte Konfigurationen
matrix/postgres/
matrix/synapse/
matrix/dev/postgres/
matrix/dev/synapse/

View File

@@ -89,7 +89,7 @@ Wenn du MinIO verwendest, setze zusatzlich:
## Deploy-Struktur
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Selfhost-Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet.
Beispiel:
@@ -102,7 +102,7 @@ Die Verzeichnisstruktur sollte dann mindestens so aussehen:
```text
/opt/fedeo/
docker-compose.yml
docker-compose.selfhost.yml
.env
backend/
frontend/
@@ -124,6 +124,14 @@ 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.
## Beispiel `.env`
Diese Datei liegt neben der `docker-compose.yml`:
@@ -176,11 +184,22 @@ OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this
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
```
## Vollstandiges Docker Compose mit optionaler S3-MinIO-Option
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.
Hinweis: Der Stack unten startet MinIO standardmassig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
## Docker Compose mit optionaler S3-MinIO-Option
Die Selfhost-Konfiguration liegt in `docker-compose.selfhost.yml`. 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.
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:
@@ -372,16 +391,22 @@ Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit M
Im Deploy-Verzeichnis:
```bash
docker compose build
docker compose up -d
docker compose -f docker-compose.selfhost.yml build
docker compose -f docker-compose.selfhost.yml up -d
```
Danach Status prufen:
```bash
docker compose ps
docker compose logs -f traefik
docker compose logs -f backend
docker compose -f docker-compose.selfhost.yml ps
docker compose -f docker-compose.selfhost.yml logs -f traefik
docker compose -f docker-compose.selfhost.yml logs -f backend
```
Wenn du Migrationen manuell ausführen möchtest:
```bash
docker compose -f docker-compose.selfhost.yml run --rm backend npm run migrate
```
## Funktionsprufung
@@ -398,16 +423,20 @@ 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
Bei neuen Versionen:
```bash
git pull
docker compose build
docker compose up -d
docker compose -f docker-compose.selfhost.yml build
docker compose -f docker-compose.selfhost.yml up -d
```
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden.
Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll.
## Backup-Empfehlung

View File

@@ -12,8 +12,10 @@ RUN apt-get update \
# 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).
# Sharp benötigt im Linux-Container native optionale Pakete, auch wenn das Lockfile auf macOS erzeugt wurde.
RUN npm install --include=optional \
&& npm install --include=optional --os=linux --cpu=x64 sharp
# Restlicher Sourcecode
COPY . .
@@ -24,5 +26,5 @@ RUN npm run build
# 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

@@ -14,26 +14,6 @@ CREATE TABLE "m2m_api_keys" (
CONSTRAINT "m2m_api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
CREATE TABLE "staff_time_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"actor_type" text NOT NULL,
"actor_user_id" uuid,
"event_time" timestamp with time zone NOT NULL,
"event_type" text NOT NULL,
"source" text NOT NULL,
"invalidates_event_id" uuid,
"related_event_id" uuid,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "time_events_actor_user_check" CHECK (
(actor_type = 'system' AND actor_user_id IS NULL)
OR
(actor_type = 'user' AND actor_user_id IS NOT NULL)
)
);
--> statement-breakpoint
CREATE TABLE "serialtypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "serialtypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
@@ -89,20 +69,15 @@ CREATE TABLE "wiki_pages" (
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "time_events" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "time_events" CASCADE;--> statement-breakpoint
ALTER TABLE "projects" ALTER COLUMN "active_phase" SET DEFAULT 'Erstkontakt';--> statement-breakpoint
ALTER TABLE "createddocuments" ADD COLUMN "serialexecution" uuid;--> statement-breakpoint
ALTER TABLE "devices" ADD COLUMN "last_seen" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "devices" ADD COLUMN "last_debug_info" jsonb;--> statement-breakpoint
ALTER TABLE "files" ADD COLUMN "size" bigint;--> statement-breakpoint
ALTER TABLE "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_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_actor_user_id_auth_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_invalidates_event_id_staff_time_events_id_fk" FOREIGN KEY ("invalidates_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_time_events" ADD CONSTRAINT "staff_time_events_related_event_id_staff_time_events_id_fk" FOREIGN KEY ("related_event_id") REFERENCES "public"."staff_time_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "serialtypes" ADD CONSTRAINT "serialtypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
@@ -113,9 +88,6 @@ ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_tenant_id_tenants_id_fk" FOR
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_parent_id_wiki_pages_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."wiki_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wiki_pages" ADD CONSTRAINT "wiki_pages_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_time_events_tenant_user_time" ON "staff_time_events" USING btree ("tenant_id","user_id","event_time");--> statement-breakpoint
CREATE INDEX "idx_time_events_created_at" ON "staff_time_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_time_events_invalidates" ON "staff_time_events" USING btree ("invalidates_event_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_tenant_idx" ON "wiki_pages" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_parent_idx" ON "wiki_pages" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "wiki_pages_entity_int_idx" ON "wiki_pages" USING btree ("tenant_id","entity_type","entity_id");--> statement-breakpoint

View File

@@ -1,108 +1 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"billingInterval" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerinventoryitems" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"customerspace" bigint,
"customerInventoryId" text NOT NULL,
"serialNumber" text,
"quantity" bigint DEFAULT 0 NOT NULL,
"manufacturer" text,
"manufacturerNumber" text,
"purchaseDate" date,
"purchasePrice" double precision DEFAULT 0,
"currentValue" double precision,
"product" bigint,
"vendor" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerspaces" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"spaceNumber" text NOT NULL,
"parentSpace" bigint,
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "entitybankaccounts" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"iban_encrypted" jsonb NOT NULL,
"bic_encrypted" jsonb NOT NULL,
"bank_name_encrypted" jsonb NOT NULL,
"description" text,
"updated_at" timestamp with time zone,
"updated_by" uuid,
"archived" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberrelations" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"type" text NOT NULL,
"billingInterval" text NOT NULL,
"billingAmount" double precision DEFAULT 0 NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;
-- Absichtlich leer: Die Objekte aus dieser generierten Migration existieren bereits in früheren Migrationen.

View File

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

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,43 @@
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,
"matrix_room_id" text,
"matrix_alias" text,
"parent_space_room_id" text,
"archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"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,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

@@ -253,6 +253,20 @@
"when": 1778194800000,
"tag": "0036_allowed_contracttypes",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1778840100000,
"tag": "0037_outgoing_sepa_mandates",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1778840200000,
"tag": "0034_profile_availability_note",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,57 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
boolean,
uniqueIndex,
index,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const communicationRooms = pgTable(
"communication_rooms",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
key: text("key").notNull(),
name: text("name").notNull(),
topic: text("topic"),
type: text("type").notNull().default("room"),
entityType: text("entity_type"),
entityId: bigint("entity_id", { mode: "number" }),
entityUuid: uuid("entity_uuid"),
matrixRoomId: text("matrix_room_id"),
matrixAlias: text("matrix_alias"),
parentSpaceRoomId: text("parent_space_room_id"),
archived: boolean("archived").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => authUsers.id),
updatedBy: uuid("updated_by").references(() => authUsers.id),
},
(table) => ({
tenantKeyIdx: uniqueIndex("communication_rooms_tenant_key_idx")
.on(table.tenantId, table.key),
tenantIdx: index("communication_rooms_tenant_idx")
.on(table.tenantId),
entityIdx: index("communication_rooms_entity_idx")
.on(table.tenantId, table.entityType, table.entityId, table.entityUuid),
})
)
export type CommunicationRoom = typeof communicationRooms.$inferSelect
export type NewCommunicationRoom = typeof communicationRooms.$inferInsert

View File

@@ -13,6 +13,7 @@ import { customers } from "./customers"
import { contacts } from "./contacts"
import { contracttypes } from "./contracttypes"
import { authUsers } from "./auth_users"
import { outgoingsepamandates } from "./outgoingsepamandates"
export const contracts = pgTable(
"contracts",
@@ -60,6 +61,9 @@ export const contracts = pgTable(
bankingOwner: text("bankingOwner"),
sepaRef: text("sepaRef"),
sepaDate: timestamp("sepaDate", { withTimezone: true }),
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
() => outgoingsepamandates.id
),
paymentType: text("paymentType"),
billingInterval: text("billingInterval"),

View File

@@ -19,6 +19,7 @@ import { projects } from "./projects"
import { plants } from "./plants"
import { authUsers } from "./auth_users"
import {serialExecutions} from "./serialexecutions";
import { outgoingsepamandates } from "./outgoingsepamandates"
export const createddocuments = pgTable("createddocuments", {
id: bigint("id", { mode: "number" })
@@ -118,6 +119,10 @@ export const createddocuments = pgTable("createddocuments", {
() => contracts.id
),
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
() => outgoingsepamandates.id
),
serialexecution: uuid("serialexecution").references(() => serialExecutions.id)
})

View File

@@ -36,6 +36,7 @@ import { authUsers } from "./auth_users"
import {files} from "./files";
import { memberrelations } from "./memberrelations";
import { contracts } from "./contracts";
import { outgoingsepamandates } from "./outgoingsepamandates";
export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" })
@@ -114,6 +115,11 @@ export const historyitems = pgTable("historyitems", {
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
() => outgoingsepamandates.id,
{ onDelete: "cascade" }
),
config: jsonb("config"),
projecttype: bigint("projecttype", { mode: "number" }).references(

View File

@@ -14,6 +14,7 @@ export * from "./branches"
export * from "./checkexecutions"
export * from "./checks"
export * from "./citys"
export * from "./communication_rooms"
export * from "./contacts"
export * from "./contracts"
export * from "./contracttypes"
@@ -56,7 +57,9 @@ export * from "./notifications_event_types"
export * from "./notifications_items"
export * from "./notifications_preferences"
export * from "./notifications_preferences_defaults"
export * from "./notification_push_subscriptions"
export * from "./ownaccounts"
export * from "./outgoingsepamandates"
export * from "./plants"
export * from "./productcategories"
export * from "./products"

View File

@@ -0,0 +1,50 @@
import {
pgTable,
uuid,
bigint,
text,
jsonb,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const notificationPushSubscriptions = pgTable(
"notification_push_subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
endpoint: text("endpoint").notNull(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
userAgent: text("user_agent"),
deviceLabel: text("device_label"),
meta: jsonb("meta"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
.notNull()
.defaultNow(),
disabledAt: timestamp("disabled_at", { withTimezone: true }),
},
(table) => ({
uniqueEndpoint: uniqueIndex("notification_push_subscriptions_endpoint_key").on(table.endpoint),
}),
)
export type NotificationPushSubscription =
typeof notificationPushSubscriptions.$inferSelect
export type NewNotificationPushSubscription =
typeof notificationPushSubscriptions.$inferInsert

View File

@@ -0,0 +1,61 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { entitybankaccounts } from "./entitybankaccounts"
import { authUsers } from "./auth_users"
export const outgoingsepamandates = pgTable("outgoingsepamandates", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
customer: bigint("customer", { mode: "number" })
.notNull()
.references(() => customers.id),
bankaccount: bigint("bankaccount", { mode: "number" })
.notNull()
.references(() => entitybankaccounts.id),
reference: text("reference").notNull(),
status: text("status").notNull().default("Entwurf"),
mandateType: text("mandate_type").notNull().default("CORE"),
sequenceType: text("sequence_type").notNull().default("RCUR"),
signedAt: timestamp("signed_at", { withTimezone: true }),
validFrom: timestamp("valid_from", { withTimezone: true }),
validUntil: timestamp("valid_until", { withTimezone: true }),
defaultMandate: boolean("default_mandate").notNull().default(false),
notes: text("notes"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type OutgoingSepaMandate = typeof outgoingsepamandates.$inferSelect
export type NewOutgoingSepaMandate = typeof outgoingsepamandates.$inferInsert

View File

@@ -91,6 +91,7 @@ export const tenants = pgTable(
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
outgoingsepamandates: true,
costcentres: true,
branches: true,
teams: true,
@@ -140,6 +141,7 @@ export const tenants = pgTable(
customerinventoryitems: { prefix: "KIA-", suffix: "", nextNumber: 1000 },
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
outgoingsepamandates: { prefix: "SEPA-", suffix: "", nextNumber: 1000 },
}),
accountChart: text("accountChart").notNull().default("skr03"),

View File

@@ -0,0 +1,7 @@
set -e
if [ "${FEDEO_RUN_MIGRATIONS:-true}" = "true" ]; then
npm run migrate
fi
exec node dist/src/index.js

10423
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,7 @@
"pg": "^8.16.3",
"pngjs": "^7.0.0",
"sharp": "^0.34.5",
"web-push": "^3.6.7",
"webdav-server": "^2.6.2",
"xmlbuilder": "^15.1.1",
"zpl-image": "^0.2.0",
@@ -63,6 +64,7 @@
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.3.0",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.8",
"prisma": "^6.15.0",
"tsx": "^4.20.5",

View File

@@ -31,6 +31,7 @@ import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-aut
import wikiRoutes from "./routes/wiki";
import portalContractRoutes from "./routes/portal/contracts";
import mcpRoutes from "./routes/mcp";
import communicationRoutes from "./routes/communication";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -55,6 +56,7 @@ import {sendMail} from "./utils/mailer";
import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3";
import { runBootstrap } from "./modules/bootstrap.service";
//Services
@@ -79,6 +81,7 @@ async function main() {
await app.register(dayjsPlugin);
await app.register(dbPlugin);
await app.register(servicesPlugin);
await runBootstrap(app);
app.addHook('preHandler', (req, reply, done) => {
console.log(req.method)
@@ -150,6 +153,7 @@ async function main() {
await subApp.register(wikiRoutes);
await subApp.register(portalContractRoutes);
await subApp.register(mcpRoutes);
await subApp.register(communicationRoutes);
},{prefix: "/api"})

View File

@@ -1,4 +1,5 @@
import { and, desc, eq, ilike, or } from "drizzle-orm"
import { randomUUID } from "node:crypto"
import {
accounts,
bankstatements,
@@ -102,7 +103,7 @@ const applyOutgoingDocumentTaxType = (
const rows = Array.isArray(payload.rows)
? payload.rows
: Array.isArray(existingRows)
? existingRows
? normalizeOutgoingDocumentRows(existingRows)
: null
if (!rows) return
@@ -124,6 +125,44 @@ const optionalArrayArg = (args: Record<string, unknown>, key: string) => {
return Array.isArray(value) ? value : null
}
const normalizeOutgoingDocumentRow = (row: unknown, index: number) => {
let normalizedRow = row
if (typeof normalizedRow === "string") {
try {
normalizedRow = JSON.parse(normalizedRow)
} catch {
throw new Error(`Position ${index + 1} ist kein gültiges JSON-Objekt`)
}
}
if (!normalizedRow || typeof normalizedRow !== "object" || Array.isArray(normalizedRow)) {
throw new Error(`Position ${index + 1} muss ein Objekt sein`)
}
const rowPayload = { ...(normalizedRow as Record<string, any>) }
rowPayload.id = rowPayload.id || randomUUID()
rowPayload.pos = rowPayload.pos || String(index + 1)
rowPayload.mode = rowPayload.mode || "free"
rowPayload.inputPrice = hasValidNumber(rowPayload.inputPrice)
? Number(rowPayload.inputPrice)
: hasValidNumber(rowPayload.price)
? Number(rowPayload.price)
: 0
rowPayload.price = hasValidNumber(rowPayload.price) ? Number(rowPayload.price) : rowPayload.inputPrice
rowPayload.quantity = hasValidNumber(rowPayload.quantity) ? Number(rowPayload.quantity) : 1
rowPayload.discountPercent = hasValidNumber(rowPayload.discountPercent) ? Number(rowPayload.discountPercent) : 0
rowPayload.linkedEntitys = Array.isArray(rowPayload.linkedEntitys) ? rowPayload.linkedEntitys : []
return rowPayload
}
const normalizeOutgoingDocumentRows = (rows: unknown) => {
if (!Array.isArray(rows)) return []
return rows.map((row, index) => normalizeOutgoingDocumentRow(row, index))
}
const buildOutgoingDocumentPayload = (
args: Record<string, unknown>,
userId: string,
@@ -142,7 +181,7 @@ const buildOutgoingDocumentPayload = (
payload.archived = false
payload.state = stringArg(args, "state") || "Entwurf"
payload.type = documentTypeArg(args)
payload.rows = optionalArrayArg(args, "rows") || []
payload.rows = normalizeOutgoingDocumentRows(optionalArrayArg(args, "rows") || [])
}
const stringFields = [
@@ -176,7 +215,7 @@ const buildOutgoingDocumentPayload = (
if (args.agriculture !== undefined) payload.agriculture = optionalObjectArg(args, "agriculture")
if (args.report !== undefined) payload.report = optionalObjectArg(args, "report") || {}
if (args.serialConfig !== undefined) payload.serialConfig = optionalObjectArg(args, "serialConfig") || {}
if (args.rows !== undefined) payload.rows = optionalArrayArg(args, "rows") || []
if (args.rows !== undefined) payload.rows = normalizeOutgoingDocumentRows(optionalArrayArg(args, "rows") || [])
if (args.usedAdvanceInvoices !== undefined) payload.usedAdvanceInvoices = optionalArrayArg(args, "usedAdvanceInvoices") || []
if (typeof args.availableInPortal === "boolean") payload.availableInPortal = args.availableInPortal
if (typeof args.advanceInvoiceResolved === "boolean") payload.advanceInvoiceResolved = args.advanceInvoiceResolved

View File

@@ -0,0 +1,490 @@
import { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import bcrypt from "bcrypt"
import {
accounts,
authProfiles,
authRoles,
authRolePermissions,
authTenantUsers,
authUserRoles,
authUsers,
branches,
filetags,
folders,
productcategories,
servicecategories,
taxTypes,
teams,
tenants,
texttemplates,
units,
} from "../../db/schema"
const adminPermissions = [
"mcp.tokens.write",
"staff.time.read_all",
"masterdata.customers.read",
"masterdata.vendors.read",
"masterdata.contacts.read",
"masterdata.products.read",
"masterdata.services.read",
"masterdata.cost_centres.read",
"masterdata.branches.read",
"masterdata.teams.read",
"masterdata.vehicles.read",
"masterdata.inventory_items.read",
"masterdata.units.read",
"accounting.outgoing_documents.read",
"accounting.outgoing_documents.write",
"accounting.accounts.read",
"accounting.incoming_invoices.read",
"accounting.incoming_invoices.write",
"accounting.bank.read",
"accounting.statement_allocations.read",
"organisation.customers.read",
"organisation.projects.read",
"organisation.plants.read",
"organisation.events.read",
"organisation.tasks.read",
"organisation.tasks.write",
]
const defaultUnits = [
{ name: "Stück", single: "Stück", multiple: "Stück", short: "Stk.", step: "1" },
{ name: "Stunde", single: "Stunde", multiple: "Stunden", short: "Std.", step: "0.25" },
{ name: "Pauschale", single: "Pauschale", multiple: "Pauschalen", short: "Psch.", step: "1" },
{ name: "Meter", single: "Meter", multiple: "Meter", short: "m", step: "0.1" },
]
const defaultTaxTypes = [
{ label: "Umsatzsteuer 19%", percentage: 19 },
{ label: "Umsatzsteuer 7%", percentage: 7 },
{ label: "Steuerfrei", percentage: 0 },
]
const defaultAccounts = [
{ number: "8400", label: "Erlöse 19% USt", accountChart: "skr03" },
{ number: "8300", label: "Erlöse 7% USt", accountChart: "skr03" },
{ number: "1200", label: "Bank", accountChart: "skr03" },
{ number: "1000", label: "Kasse", accountChart: "skr03" },
{ number: "1400", label: "Forderungen aus Lieferungen und Leistungen", accountChart: "skr03" },
{ number: "1600", label: "Verbindlichkeiten aus Lieferungen und Leistungen", accountChart: "skr03" },
]
async function ensureGlobalDefaults(server: FastifyInstance, userId: string) {
for (const unit of defaultUnits) {
const existing = await server.db.select({ id: units.id }).from(units).where(eq(units.name, unit.name)).limit(1)
if (!existing.length) await server.db.insert(units).values(unit)
}
for (const taxType of defaultTaxTypes) {
const existing = await server.db
.select({ id: taxTypes.id })
.from(taxTypes)
.where(eq(taxTypes.percentage, taxType.percentage))
.limit(1)
if (!existing.length) {
await server.db.insert(taxTypes).values({
...taxType,
updatedAt: new Date(),
updatedBy: userId,
})
}
}
for (const account of defaultAccounts) {
const existing = await server.db
.select({ id: accounts.id })
.from(accounts)
.where(and(eq(accounts.accountChart, account.accountChart), eq(accounts.number, account.number)))
.limit(1)
if (!existing.length) {
await server.db.insert(accounts).values({
...account,
description: "FEDEO Standardkonto",
})
}
}
}
async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: number, userId: string) {
const currentYear = new Date().getFullYear()
const timestamp = new Date()
const tagDefaults = [
{ name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices" },
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes" },
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders" },
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes" },
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices" },
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders" },
]
for (const tag of tagDefaults) {
const existing = await server.db
.select({ id: filetags.id })
.from(filetags)
.where(and(eq(filetags.tenant, tenantId), eq(filetags.name, tag.name)))
.limit(1)
if (!existing.length) {
await server.db.insert(filetags).values({
tenant: tenantId,
...tag,
})
}
}
const allTags = await server.db.select().from(filetags).where(eq(filetags.tenant, tenantId))
const tagByCreatedType = new Map(allTags.map((tag) => [tag.createdDocumentType, tag.id]))
const tagByIncomingType = new Map(allTags.map((tag) => [tag.incomingDocumentType, tag.id]))
const rootFolders = [
{ name: "Ausgangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-document-text" },
{ name: "Angebote", function: "yearSubCategory" as const, icon: "i-heroicons-document-duplicate" },
{ name: "Auftragsbestätigungen", function: "yearSubCategory" as const, icon: "i-heroicons-clipboard-document-check" },
{ name: "Lieferscheine", function: "yearSubCategory" as const, icon: "i-heroicons-truck" },
{ name: "Eingangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-inbox-arrow-down" },
{ name: "Belege Bankeinzahlung", function: "yearSubCategory" as const, icon: "i-heroicons-banknotes" },
]
for (const folder of rootFolders) {
const existing = await server.db
.select({ id: folders.id })
.from(folders)
.where(and(eq(folders.tenant, tenantId), eq(folders.name, folder.name)))
.limit(1)
if (!existing.length) {
await server.db.insert(folders).values({
tenant: tenantId,
name: folder.name,
function: folder.function,
icon: folder.icon,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: userId,
})
}
}
const allFolders = await server.db.select().from(folders).where(eq(folders.tenant, tenantId))
const rootFolderByName = new Map(allFolders.filter((folder) => !folder.parent).map((folder) => [folder.name, folder.id]))
const yearFolders = [
{
parentName: "Ausgangsrechnungen",
function: "invoices" as const,
icon: "i-heroicons-document-text",
standardFiletype: tagByCreatedType.get("invoices"),
},
{
parentName: "Angebote",
function: "quotes" as const,
icon: "i-heroicons-document-duplicate",
standardFiletype: tagByCreatedType.get("quotes"),
},
{
parentName: "Auftragsbestätigungen",
function: "confirmationOrders" as const,
icon: "i-heroicons-clipboard-document-check",
standardFiletype: tagByCreatedType.get("confirmationOrders"),
},
{
parentName: "Lieferscheine",
function: "deliveryNotes" as const,
icon: "i-heroicons-truck",
standardFiletype: tagByCreatedType.get("deliveryNotes"),
},
{
parentName: "Eingangsrechnungen",
function: "incomingInvoices" as const,
icon: "i-heroicons-inbox-arrow-down",
standardFiletype: tagByIncomingType.get("invoices"),
},
{
parentName: "Belege Bankeinzahlung",
function: "deposit" as const,
icon: "i-heroicons-banknotes",
},
]
for (const folder of yearFolders) {
const parent = rootFolderByName.get(folder.parentName)
if (!parent) continue
const existing = await server.db
.select({ id: folders.id })
.from(folders)
.where(and(eq(folders.tenant, tenantId), eq(folders.parent, parent), eq(folders.year, currentYear)))
.limit(1)
if (!existing.length) {
await server.db.insert(folders).values({
tenant: tenantId,
name: String(currentYear),
parent,
function: folder.function,
year: currentYear,
icon: folder.icon,
standardFiletype: folder.standardFiletype,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: userId,
})
}
}
}
export async function ensureTenantBaseData(server: FastifyInstance, tenantId: number, adminUserId: string) {
await ensureGlobalDefaults(server, adminUserId)
await ensureTenantFileDefaults(server, tenantId, adminUserId)
const [adminRole] = await server.db
.select({ id: authRoles.id })
.from(authRoles)
.where(and(eq(authRoles.tenant_id, tenantId), eq(authRoles.name, "Administrator")))
.limit(1)
let adminRoleId = adminRole?.id
if (!adminRoleId) {
const [createdRole] = await server.db
.insert(authRoles)
.values({
name: "Administrator",
description: "Vollzugriff für die Administration dieses Mandanten",
tenant_id: tenantId,
created_by: adminUserId,
})
.returning({ id: authRoles.id })
adminRoleId = createdRole.id
}
for (const permission of adminPermissions) {
const existing = await server.db
.select()
.from(authRolePermissions)
.where(and(eq(authRolePermissions.role_id, adminRoleId), eq(authRolePermissions.permission, permission)))
.limit(1)
if (!existing.length) {
await server.db.insert(authRolePermissions).values({
role_id: adminRoleId,
permission,
})
}
}
const membership = await server.db
.select()
.from(authTenantUsers)
.where(and(eq(authTenantUsers.tenant_id, tenantId), eq(authTenantUsers.user_id, adminUserId)))
.limit(1)
if (!membership.length) {
await server.db.insert(authTenantUsers).values({
tenant_id: tenantId,
user_id: adminUserId,
created_by: adminUserId,
})
}
const roleAssignment = await server.db
.select()
.from(authUserRoles)
.where(and(
eq(authUserRoles.tenant_id, tenantId),
eq(authUserRoles.user_id, adminUserId),
eq(authUserRoles.role_id, adminRoleId),
))
.limit(1)
if (!roleAssignment.length) {
await server.db.insert(authUserRoles).values({
tenant_id: tenantId,
user_id: adminUserId,
role_id: adminRoleId,
created_by: adminUserId,
})
}
const profile = await server.db
.select()
.from(authProfiles)
.where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.user_id, adminUserId)))
.limit(1)
if (!profile.length) {
await server.db.insert(authProfiles).values({
tenant_id: tenantId,
user_id: adminUserId,
first_name: process.env.FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME || "Admin",
last_name: process.env.FEDEO_BOOTSTRAP_ADMIN_LAST_NAME || "Benutzer",
email: process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase(),
active: true,
})
}
const branch = await server.db
.select({ id: branches.id })
.from(branches)
.where(and(eq(branches.tenant, tenantId), eq(branches.name, "Hauptstandort")))
.limit(1)
let branchId = branch[0]?.id
if (!branchId) {
const [createdBranch] = await server.db.insert(branches).values({
tenant: tenantId,
name: "Hauptstandort",
number: "001",
description: "Standardstandort",
updatedAt: new Date(),
updatedBy: adminUserId,
}).returning({ id: branches.id })
branchId = createdBranch.id
}
const team = await server.db
.select({ id: teams.id })
.from(teams)
.where(and(eq(teams.tenant, tenantId), eq(teams.name, "Standardteam")))
.limit(1)
if (!team.length) {
await server.db.insert(teams).values({
tenant: tenantId,
name: "Standardteam",
description: "Automatisch angelegtes Standardteam",
branch: branchId,
updatedAt: new Date(),
updatedBy: adminUserId,
})
}
const defaultProductCategory = await server.db
.select({ id: productcategories.id })
.from(productcategories)
.where(and(eq(productcategories.tenant, tenantId), eq(productcategories.name, "Standard")))
.limit(1)
if (!defaultProductCategory.length) {
await server.db.insert(productcategories).values({
tenant: tenantId,
name: "Standard",
description: "Standardkategorie",
updatedAt: new Date(),
updatedBy: adminUserId,
})
}
const defaultServiceCategory = await server.db
.select({ id: servicecategories.id })
.from(servicecategories)
.where(and(eq(servicecategories.tenant, tenantId), eq(servicecategories.name, "Standard")))
.limit(1)
if (!defaultServiceCategory.length) {
await server.db.insert(servicecategories).values({
tenant: tenantId,
name: "Standard",
description: "Standardkategorie",
updated_at: new Date(),
updated_by: adminUserId,
})
}
const templateDefaults = [
{ name: "Standard Einleitung", pos: "startText" as const, text: "<p>vielen Dank für Ihre Anfrage.</p>" },
{ name: "Standard Schluss", pos: "endText" as const, text: "<p>Mit freundlichen Grüßen</p>" },
]
for (const template of templateDefaults) {
const existing = await server.db
.select({ id: texttemplates.id })
.from(texttemplates)
.where(and(eq(texttemplates.tenant, tenantId), eq(texttemplates.name, template.name)))
.limit(1)
if (!existing.length) {
await server.db.insert(texttemplates).values({
tenant: tenantId,
name: template.name,
text: template.text,
pos: template.pos,
default: true,
updatedAt: new Date(),
updatedBy: adminUserId,
})
}
}
}
export async function runBootstrap(server: FastifyInstance) {
const email = process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase()
const password = process.env.FEDEO_BOOTSTRAP_ADMIN_PASSWORD
if (!email && !password) return
if (!email || !password) {
throw new Error("FEDEO_BOOTSTRAP_ADMIN_EMAIL und FEDEO_BOOTSTRAP_ADMIN_PASSWORD müssen gemeinsam gesetzt sein")
}
const [existingUser] = await server.db
.select()
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1)
let adminUser = existingUser
if (!adminUser) {
const [createdUser] = await server.db.insert(authUsers).values({
email,
passwordHash: await bcrypt.hash(password, 10),
is_admin: true,
multiTenant: true,
must_change_password: false,
updatedAt: new Date(),
}).returning()
adminUser = createdUser
console.log(`✅ Bootstrap-Admin angelegt: ${email}`)
} else if (!adminUser.is_admin) {
const [updatedUser] = await server.db.update(authUsers).set({
is_admin: true,
updatedAt: new Date(),
}).where(eq(authUsers.id, adminUser.id)).returning()
adminUser = updatedUser
console.log(`✅ Bootstrap-Adminrechte gesetzt: ${email}`)
}
const tenantName = process.env.FEDEO_BOOTSTRAP_TENANT_NAME?.trim() || "FEDEO"
const tenantShort = process.env.FEDEO_BOOTSTRAP_TENANT_SHORT?.trim() || "FEDEO"
const [existingTenant] = await server.db
.select()
.from(tenants)
.where(eq(tenants.short, tenantShort))
.limit(1)
let tenant = existingTenant
if (!tenant) {
const [createdTenant] = await server.db.insert(tenants).values({
name: tenantName,
short: tenantShort,
updatedAt: new Date(),
updatedBy: adminUser.id,
}).returning()
tenant = createdTenant
console.log(`✅ Bootstrap-Mandant angelegt: ${tenant.name}`)
}
await ensureTenantBaseData(server, tenant.id, adminUser.id)
console.log("✅ Bootstrap-Grunddaten geprüft")
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,51 @@
// services/notification.service.ts
import type { FastifyInstance } from 'fastify';
import {secrets} from "../utils/secrets";
import { eq } from "drizzle-orm";
import { notificationsEventTypes, notificationsItems } from "../../db/schema";
import type { FastifyInstance } from "fastify"
import webPush from "web-push"
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
import {
authUsers,
notificationPushSubscriptions,
notificationsEventTypes,
notificationsItems,
notificationsPreferences,
notificationsPreferencesDefaults,
} from "../../db/schema"
import { secrets } from "../utils/secrets"
export type NotificationStatus = 'queued' | 'sent' | 'failed';
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
export interface TriggerInput {
tenantId: number;
userId: string; // muss auf public.auth_users.id zeigen
eventType: string; // muss in notifications_event_types existieren
title: string; // Betreff/Title
message: string; // Klartext-Inhalt
payload?: Record<string, unknown>;
tenantId: number
userId?: string
userIds?: string[]
eventType: string
title: string
message: string
payload?: Record<string, unknown>
channels?: NotificationChannel[]
}
export interface PushSubscriptionInput {
endpoint: string
keys: {
p256dh: string
auth: string
}
deviceLabel?: string
meta?: Record<string, unknown>
}
export interface UserDirectoryInfo {
email?: string;
email?: string
}
export type UserDirectory = (server: FastifyInstance, userId: string, tenantId: number) => Promise<UserDirectoryInfo | null>;
export type UserDirectory = (
server: FastifyInstance,
userId: string,
tenantId: number
) => Promise<UserDirectoryInfo | null>
const DEFAULT_CHANNELS: NotificationChannel[] = ["inapp"]
export class NotificationService {
constructor(
@@ -27,99 +53,355 @@ export class NotificationService {
private getUser: UserDirectory
) {}
/**
* Löst eine E-Mail-Benachrichtigung aus:
* - Validiert den Event-Typ
* - Legt einen Datensatz in notifications_items an (status: queued)
* - Versendet E-Mail (FEDEO Branding)
* - Aktualisiert status/sent_at bzw. error
*/
async trigger(input: TriggerInput) {
const { tenantId, userId, eventType, title, message, payload } = input;
const tenantId = input.tenantId
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
// 1) Event-Typ prüfen (aktiv?)
const eventTypeRows = await this.server.db
.select()
.from(notificationsEventTypes)
.where(eq(notificationsEventTypes.eventKey, eventType))
.limit(1)
const eventTypeRow = eventTypeRows[0]
if (!tenantId) throw new Error("tenantId fehlt")
if (!userIds.length) throw new Error("Keine Empfänger angegeben")
if (!eventTypeRow || eventTypeRow.isActive !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
const eventType = await this.getActiveEventType(input.eventType)
const allowedChannels = this.normalizeChannels(eventType.allowedChannels)
const requestedChannels = input.channels?.length ? input.channels : allowedChannels
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
if (!channels.length) {
return { success: true, created: 0, delivered: 0, skipped: userIds.length }
}
// 2) Zieladresse beschaffen
const user = await this.getUser(this.server, userId, tenantId);
if (!user?.email) {
throw new Error(`Nutzer ${userId} hat keine E-Mail-Adresse`);
}
const results = []
// 3) Notification anlegen (status: queued)
const insertedRows = await this.server.db
for (const userId of userIds) {
const enabledChannels = await this.resolveEnabledChannels({
tenantId,
userId,
eventType: input.eventType,
channels,
})
for (const channel of enabledChannels) {
const itemRows = await this.server.db
.insert(notificationsItems)
.values({
tenantId,
userId,
eventType,
title,
message,
payload: payload ?? null,
channel: 'email',
status: 'queued'
eventType: input.eventType,
title: input.title,
message: input.message,
payload: input.payload ?? null,
channel,
status: "queued",
})
.returning({ id: notificationsItems.id })
const inserted = insertedRows[0]
.returning()
if (!inserted) {
throw new Error("Fehler beim Einfügen der Notification");
const item = itemRows[0]
if (!item) continue
results.push(await this.deliver(item))
}
}
// 4) E-Mail versenden
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
}
async listForUser(tenantId: number, userId: string, limit = 50) {
return await this.server.db
.select()
.from(notificationsItems)
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
eq(notificationsItems.channel, "inapp")
))
.orderBy(desc(notificationsItems.createdAt))
.limit(Math.min(Math.max(limit, 1), 100))
}
async markRead(tenantId: number, userId: string, notificationId: string) {
const rows = await this.server.db
.update(notificationsItems)
.set({ readAt: new Date(), status: "read" })
.where(and(
eq(notificationsItems.id, notificationId),
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId)
))
.returning()
return rows[0] || null
}
async registerPushSubscription(
tenantId: number,
userId: string,
subscription: PushSubscriptionInput,
userAgent?: string
) {
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
throw new Error("Push-Subscription ist unvollständig")
}
const rows = await this.server.db
.insert(notificationPushSubscriptions)
.values({
tenantId,
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
deviceLabel: subscription.deviceLabel,
meta: subscription.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
})
.onConflictDoUpdate({
target: notificationPushSubscriptions.endpoint,
set: {
tenantId,
userId,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
deviceLabel: subscription.deviceLabel,
meta: subscription.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
},
})
.returning()
return rows[0]
}
async disablePushSubscription(tenantId: number, userId: string, endpoint: string) {
await this.server.db
.update(notificationPushSubscriptions)
.set({ disabledAt: new Date() })
.where(and(
eq(notificationPushSubscriptions.tenantId, tenantId),
eq(notificationPushSubscriptions.userId, userId),
eq(notificationPushSubscriptions.endpoint, endpoint)
))
return { success: true }
}
getPublicPushConfig() {
return {
configured: Boolean(secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY),
publicKey: secrets.WEB_PUSH_PUBLIC_KEY || "",
}
}
private async getActiveEventType(eventType: string) {
const rows = await this.server.db
.select()
.from(notificationsEventTypes)
.where(eq(notificationsEventTypes.eventKey, eventType))
.limit(1)
const row = rows[0]
if (!row || row.isActive !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`)
}
return row
}
private normalizeChannels(value: unknown): NotificationChannel[] {
if (!Array.isArray(value)) return DEFAULT_CHANNELS
const valid = new Set(["inapp", "email", "push", "webhook", "sms"])
const channels = value.filter((channel): channel is NotificationChannel =>
typeof channel === "string" && valid.has(channel)
)
return channels.length ? channels : DEFAULT_CHANNELS
}
private async resolveEnabledChannels(input: {
tenantId: number
userId: string
eventType: string
channels: NotificationChannel[]
}) {
const prefs = await this.server.db
.select()
.from(notificationsPreferences)
.where(and(
eq(notificationsPreferences.tenantId, input.tenantId),
eq(notificationsPreferences.userId, input.userId),
eq(notificationsPreferences.eventType, input.eventType),
inArray(notificationsPreferences.channel, input.channels)
))
const defaults = await this.server.db
.select()
.from(notificationsPreferencesDefaults)
.where(and(
eq(notificationsPreferencesDefaults.tenantId, input.tenantId),
eq(notificationsPreferencesDefaults.eventKey, input.eventType),
inArray(notificationsPreferencesDefaults.channel, input.channels)
))
return input.channels.filter((channel) => {
const userPref = prefs.find((pref) => pref.channel === channel)
if (userPref) return userPref.enabled
const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled
return true
})
}
private async deliver(item: typeof notificationsItems.$inferSelect) {
if (item.channel === "inapp") {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }
}
if (item.channel === "push") {
return await this.deliverPush(item)
}
if (item.channel === "email") {
return await this.deliverEmail(item)
}
await this.markFailed(item.id, `Kein Zusteller für Kanal ${item.channel}`)
return { success: false, id: item.id, channel: item.channel }
}
private async deliverPush(item: typeof notificationsItems.$inferSelect) {
if (!secrets.WEB_PUSH_PUBLIC_KEY || !secrets.WEB_PUSH_PRIVATE_KEY) {
await this.markFailed(item.id, "Web Push ist nicht konfiguriert")
return { success: false, id: item.id, channel: item.channel }
}
webPush.setVapidDetails(
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
secrets.WEB_PUSH_PUBLIC_KEY,
secrets.WEB_PUSH_PRIVATE_KEY
)
const subscriptions = await this.server.db
.select()
.from(notificationPushSubscriptions)
.where(and(
eq(notificationPushSubscriptions.tenantId, item.tenantId),
eq(notificationPushSubscriptions.userId, item.userId),
isNull(notificationPushSubscriptions.disabledAt)
))
if (!subscriptions.length) {
await this.markFailed(item.id, "Keine aktive Push-Subscription")
return { success: false, id: item.id, channel: item.channel }
}
const payload = JSON.stringify({
id: item.id,
title: item.title,
message: item.message,
payload: item.payload || {},
})
let delivered = 0
const errors: string[] = []
for (const subscription of subscriptions) {
try {
await this.sendEmail(user.email, title, message);
await webPush.sendNotification({
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}, payload)
delivered++
await this.server.db
.update(notificationsItems)
.set({ status: 'sent', sentAt: new Date() })
.where(eq(notificationsItems.id, inserted.id));
.update(notificationPushSubscriptions)
.set({ lastSeenAt: new Date() })
.where(eq(notificationPushSubscriptions.id, subscription.id))
} catch (error: any) {
errors.push(error?.message || String(error))
return { success: true, id: inserted.id };
} catch (err: any) {
if (error?.statusCode === 404 || error?.statusCode === 410) {
await this.server.db
.update(notificationsItems)
.set({ status: 'failed', error: String(err?.message || err) })
.where(eq(notificationsItems.id, inserted.id));
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };
.update(notificationPushSubscriptions)
.set({ disabledAt: new Date() })
.where(eq(notificationPushSubscriptions.id, subscription.id))
}
}
}
// ---- private helpers ------------------------------------------------------
if (delivered > 0) {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel, delivered }
}
await this.markFailed(item.id, errors.join("; ") || "Push konnte nicht zugestellt werden")
return { success: false, id: item.id, channel: item.channel }
}
private async deliverEmail(item: typeof notificationsItems.$inferSelect) {
try {
const user = await this.getUser(this.server, item.userId, item.tenantId)
if (!user?.email) throw new Error(`Nutzer ${item.userId} hat keine E-Mail-Adresse`)
await this.sendEmail(user.email, item.title, item.message)
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }
} catch (error: any) {
await this.markFailed(item.id, error?.message || "E-Mail Versand fehlgeschlagen")
this.server.log.error({ err: error, notificationId: item.id }, "E-Mail Versand fehlgeschlagen")
return { success: false, id: item.id, channel: item.channel }
}
}
private async markSent(id: string) {
await this.server.db
.update(notificationsItems)
.set({ status: "sent", sentAt: new Date() })
.where(eq(notificationsItems.id, id))
}
private async markFailed(id: string, error: string) {
await this.server.db
.update(notificationsItems)
.set({ status: "failed", error })
.where(eq(notificationsItems.id, id))
}
private async sendEmail(to: string, subject: string, message: string) {
const nodemailer = await import('nodemailer');
const nodemailer = await import("nodemailer")
const transporter = nodemailer.createTransport({
host: secrets.MAILER_SMTP_HOST,
port: Number(secrets.MAILER_SMTP_PORT),
secure: secrets.MAILER_SMTP_SSL === 'true',
secure: secrets.MAILER_SMTP_SSL === "true",
auth: {
user: secrets.MAILER_SMTP_USER,
pass: secrets.MAILER_SMTP_PASS
}
});
pass: secrets.MAILER_SMTP_PASS,
},
})
const html = this.renderFedeoHtml(subject, message);
const html = this.renderFedeoHtml(subject, message)
await transporter.sendMail({
from: secrets.MAILER_FROM,
to,
subject,
text: message,
html
});
html,
})
}
private renderFedeoHtml(title: string, message: string) {
@@ -133,18 +415,17 @@ export class NotificationService {
<p style="font-size:12px;color:#666">Automatisch generiert von FEDEO</p>
</div>
</body></html>
`;
`
}
// simple escaping (ausreichend für unser Template)
private escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private nl2br(s: string) {
return s.replace(/\n/g, '<br/>');
return s.replace(/\n/g, "<br/>")
}
}

View File

@@ -14,6 +14,7 @@ import {
} from "../../db/schema";
import { generateRandomPassword, hashPassword } from "../utils/password";
import { sendMail } from "../utils/mailer";
import { ensureTenantBaseData } from "../modules/bootstrap.service";
export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => {
@@ -825,6 +826,7 @@ export default async function adminRoutes(server: FastifyInstance) {
});
await createTenantSeeds(createdTenant.id, currentUser.id);
await ensureTenantBaseData(server, createdTenant.id, currentUser.id);
return { tenant: createdTenant };
} catch (err) {

View File

@@ -0,0 +1,323 @@
import { FastifyInstance } from "fastify"
import { and, eq, ne } from "drizzle-orm"
import { authTenantUsers, authUsers } from "../../db/schema"
import { matrixService } from "../modules/matrix.service"
import { NotificationService, UserDirectory } from "../modules/notification.service"
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
const rows = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
return rows[0] || null
}
export default async function communicationRoutes(server: FastifyInstance) {
const matrix = matrixService(server)
const notifications = new NotificationService(server, getUserDirectory)
const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => {
req.log.error(err)
return reply
.code(err.statusCode || 500)
.send({ error: err.message || fallbackMessage })
}
const roomOptionsFromRequest = (req: any) => {
const params = req.params as { roomKey?: string }
const body = (req.body || {}) as {
key?: string
name?: string
topic?: string
type?: string
entityType?: string | null
entityId?: number | null
entityUuid?: string | null
}
return {
key: params.roomKey || body.key,
name: body.name,
topic: body.topic,
type: body.type,
entityType: body.entityType,
entityId: body.entityId,
entityUuid: body.entityUuid,
}
}
const callModeFromRequest = (req: any): "audio" | "video" => {
const body = (req.body || {}) as { mode?: string }
return body.mode === "audio" ? "audio" : "video"
}
const notifyTenantUsersAboutCall = async (req: any, room: { key?: string; name?: string }, mode: "audio" | "video") => {
if (!req.user.tenant_id) return
try {
const recipientRows = await server.db
.select({ userId: authTenantUsers.user_id })
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
ne(authTenantUsers.user_id, req.user.user_id)
))
const userIds = recipientRows.map((row) => row.userId)
if (!userIds.length) return
await notifications.trigger({
tenantId: req.user.tenant_id,
userIds,
eventType: "communication.call.started",
title: mode === "audio" ? "Audioanruf gestartet" : "Videokonferenz gestartet",
message: `${room.name || room.key || "Ein Chatraum"} hat eine laufende Besprechung.`,
payload: {
link: "/communication/chat",
roomKey: room.key,
roomName: room.name,
mode,
},
channels: ["inapp", "push"],
})
} catch (err) {
req.log.error({ err }, "Call-Benachrichtigung konnte nicht ausgelöst werden")
}
}
server.get("/communication/matrix/status", async () => {
return matrix.getStatus()
})
server.get("/communication/matrix/me", async (req) => {
const userId = req.user.user_id
return {
matrixUserId: await matrix.matrixUserIdForUser(userId, req.user.tenant_id),
displayName: await matrix.getCurrentUserDisplayName(userId, req.user.tenant_id),
}
})
server.post("/communication/matrix/me/provision", async (req, reply) => {
try {
return await matrix.provisionCurrentUser(req.user.user_id, req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix provisioning failed")
}
})
server.get("/communication/matrix/tenant-space", async (req, reply) => {
try {
return await matrix.getTenantSpaceStatus(req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix tenant space status failed")
}
})
server.post("/communication/matrix/tenant-space/provision", async (req, reply) => {
try {
return await matrix.provisionCurrentTenantSpace(req.user.user_id, req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix tenant space provisioning failed")
}
})
server.get("/communication/matrix/rooms", async (req, reply) => {
try {
return await matrix.listTenantRooms(req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix rooms failed")
}
})
server.post("/communication/matrix/rooms", async (req, reply) => {
try {
return await matrix.provisionTenantRoom(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
}
})
server.get("/communication/matrix/rooms/general", async (req, reply) => {
try {
return await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room status failed")
}
})
server.post("/communication/matrix/rooms/general/provision", async (req, reply) => {
try {
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
key: "allgemein",
name: "Allgemeiner Chat",
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
}
})
server.get("/communication/matrix/rooms/general/messages", async (req, reply) => {
try {
return await matrix.getGeneralRoomMessages(req.user.user_id, req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix messages failed")
}
})
server.get("/communication/matrix/rooms/general/members", async (req, reply) => {
try {
return await matrix.getGeneralRoomMembers(req.user.user_id, req.user.tenant_id)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix members failed")
}
})
server.post("/communication/matrix/rooms/general/session", async (req, reply) => {
try {
return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, {
key: "allgemein",
name: "Allgemeiner Chat",
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix session failed")
}
})
server.post("/communication/matrix/rooms/general/call-session", async (req, reply) => {
try {
const room = {
key: "allgemein",
name: "Allgemeiner Chat",
}
const session = await matrix.createLiveKitRoomSession(req.user.user_id, req.user.tenant_id, room)
await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req))
return session
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix call session failed")
}
})
server.post("/communication/matrix/rooms/general/members/sync", async (req, reply) => {
try {
return await matrix.syncTenantRoomMembers(req.user.user_id, req.user.tenant_id, {
key: "allgemein",
name: "Allgemeiner Chat",
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member sync failed")
}
})
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
try {
const body = req.body as { text?: string }
return await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message send failed")
}
})
server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => {
try {
const params = req.params as { roomKey: string }
return await matrix.getTenantRoomStatus(req.user.tenant_id, params.roomKey)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room status failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/provision", async (req, reply) => {
try {
return await matrix.provisionTenantRoom(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room provisioning failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try {
return await matrix.getTenantRoomMessages(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix messages failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
try {
return await matrix.getTenantRoomMembers(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix members failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => {
try {
return await matrix.createElementRoomSession(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix session failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/call-session", async (req, reply) => {
try {
const room = roomOptionsFromRequest(req)
const session = await matrix.createLiveKitRoomSession(
req.user.user_id,
req.user.tenant_id,
room
)
await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req))
return session
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix call session failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/members/sync", async (req, reply) => {
try {
return await matrix.syncTenantRoomMembers(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member sync failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
try {
const body = req.body as { text?: string }
return await matrix.sendTenantRoomMessage(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
body.text || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix message send failed")
}
})
}

View File

@@ -67,6 +67,45 @@ const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDat
}
const createSepaExport = async (server: FastifyInstance, req: any, idsToExport: number[], creditorBankaccountId: number) => {
const exportData = await createSEPAExport(server, idsToExport, req.user.tenant_id, creditorBankaccountId)
const fileKey = `${req.user.tenant_id}/exports/SEPA_${dayjs().format("YYYY-MM-DD")}_${randomUUID()}.xml`
await s3.send(
new PutObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
Body: exportData.buffer,
ContentType: "application/xml",
})
)
const url = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
}),
{ expiresIn: 60 * 60 * 24 }
)
const inserted = await server.db
.insert(generatedexports)
.values({
tenantId: req.user.tenant_id,
startDate: exportData.startDate,
endDate: exportData.endDate,
validUntil: dayjs().add(24, "hours").toDate(),
filePath: fileKey,
url,
type: "sepa",
})
.returning()
console.log(inserted[0])
}
export default async function exportRoutes(server: FastifyInstance) {
//Export DATEV
@@ -94,17 +133,24 @@ export default async function exportRoutes(server: FastifyInstance) {
})
server.post("/exports/sepa", async (req, reply) => {
const { idsToExport } = req.body as {
const { idsToExport, creditorBankaccountId } = req.body as {
idsToExport: Array<number>
creditorBankaccountId: number
}
if (!idsToExport?.length || !creditorBankaccountId) {
return reply.send({
success: false,
message: "Belege und Gläubigerkonto sind Pflichtfelder."
})
}
reply.send({success:true})
setImmediate(async () => {
try {
await createSEPAExport(server, idsToExport, req.user.tenant_id)
await createSepaExport(server, req, idsToExport, creditorBankaccountId)
console.log("Job done ✅")
} catch (err) {
console.error("Job failed ❌", err)

View File

@@ -27,6 +27,7 @@ const columnMap: Record<string, any> = {
customerspaces: historyitems.customerspace,
customerinventoryitems: historyitems.customerinventoryitem,
memberrelations: historyitems.memberrelation,
outgoingsepamandates: historyitems.outgoingsepamandate,
};
const insertFieldMap: Record<string, string> = {
@@ -53,6 +54,7 @@ const insertFieldMap: Record<string, string> = {
customerspaces: "customerspace",
customerinventoryitems: "customerinventoryitem",
memberrelations: "memberrelation",
outgoingsepamandates: "outgoingsepamandate",
}
const parseId = (value: string) => {

View File

@@ -1,31 +1,92 @@
// routes/notifications.routes.ts
import { FastifyInstance } from 'fastify';
import { NotificationService, UserDirectory } from '../modules/notification.service';
import { eq } from "drizzle-orm";
import { authUsers } from "../../db/schema";
import { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { authUsers } from "../../db/schema"
import { NotificationService, UserDirectory } from "../modules/notification.service"
// Beispiel: E-Mail aus eigener User-Tabelle laden
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
const rows = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
const data = rows[0]
if (!data) return null;
return { email: data.email };
};
if (!data) return null
return { email: data.email }
}
const requireTenant = (tenantId: number | null) => {
if (!tenantId) throw new Error("Kein aktiver Mandant")
return tenantId
}
export default async function notificationsRoutes(server: FastifyInstance) {
const svc = new NotificationService(server, getUserDirectory);
const svc = new NotificationService(server, getUserDirectory)
server.post('/notifications/trigger', async (req, reply) => {
server.get("/notifications", async (req) => {
const limit = Number((req.query as { limit?: string })?.limit || 50)
return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit)
})
server.post("/notifications/:id/read", async (req, reply) => {
const params = req.params as { id: string }
const item = await svc.markRead(requireTenant(req.user.tenant_id), req.user.user_id, params.id)
if (!item) return reply.code(404).send({ error: "Benachrichtigung nicht gefunden" })
return item
})
server.get("/notifications/push/config", async () => {
return svc.getPublicPushConfig()
})
server.post("/notifications/push/subscribe", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const userAgent = req.headers["user-agent"]
const subscription = await svc.registerPushSubscription(
tenantId,
req.user.user_id,
req.body as any,
Array.isArray(userAgent) ? userAgent.join(" ") : userAgent
)
return {
success: true,
id: subscription?.id,
}
})
server.delete("/notifications/push/subscribe", async (req) => {
const body = (req.body || {}) as { endpoint?: string }
if (!body.endpoint) throw new Error("endpoint fehlt")
return await svc.disablePushSubscription(requireTenant(req.user.tenant_id), req.user.user_id, body.endpoint)
})
server.post("/notifications/test-push", async (req) => {
return await svc.trigger({
tenantId: requireTenant(req.user.tenant_id),
userId: req.user.user_id,
eventType: "system.test_push",
title: "FEDEO Desktop Push ist aktiv",
message: "Diese Testbenachrichtigung wurde von FEDEO selbst zugestellt.",
payload: {
link: "/",
icon: "/favicon.ico",
},
channels: ["inapp", "push"],
})
})
server.post("/notifications/trigger", async (req, reply) => {
try {
const res = await svc.trigger(req.body as any);
reply.send(res);
const body = req.body as any
const tenantId = body.tenantId || req.user.tenant_id
const res = await svc.trigger({
...body,
tenantId: requireTenant(tenantId),
})
reply.send(res)
} catch (err: any) {
server.log.error(err);
reply.code(500).send({ error: err.message });
server.log.error(err)
reply.code(500).send({ error: err.message })
}
});
})
}

View File

@@ -11,7 +11,7 @@ import {
sql,
} from "drizzle-orm"
import { authProfiles, costcentres } from "../../../db/schema";
import { authProfiles, costcentres, customers, entitybankaccounts } from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
@@ -21,6 +21,9 @@ import { decrypt, encrypt } from "../../utils/crypt";
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
const OUTGOING_SEPA_MANDATE_STATUSES = new Set(["Entwurf", "Aktiv", "Widerrufen", "Abgelaufen"])
const OUTGOING_SEPA_MANDATE_TYPES = new Set(["CORE", "B2B"])
const OUTGOING_SEPA_SEQUENCE_TYPES = new Set(["RCUR", "OOFF", "FRST", "FNAL"])
// -------------------------------------------------------------
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
@@ -365,6 +368,65 @@ function prepareEntityBankAccountPayload(payload: Record<string, any>, requireAl
return { data: result }
}
async function validateOutgoingSepaMandatePayload(
server: FastifyInstance,
tenantId: number,
payload: Record<string, any>,
existing: Record<string, any> | null = null
) {
const customerId = Number(payload.customer ?? existing?.customer)
const bankaccountId = Number(payload.bankaccount ?? existing?.bankaccount)
if (!customerId || !bankaccountId) {
return "Kunde und Bankverbindung sind Pflichtfelder."
}
const status = payload.status ?? existing?.status ?? "Entwurf"
if (!OUTGOING_SEPA_MANDATE_STATUSES.has(status)) {
return "Ungültiger Mandatsstatus."
}
const mandateType = payload.mandateType ?? existing?.mandateType ?? "CORE"
if (!OUTGOING_SEPA_MANDATE_TYPES.has(mandateType)) {
return "Ungültiger Mandatstyp."
}
const sequenceType = payload.sequenceType ?? existing?.sequenceType ?? "RCUR"
if (!OUTGOING_SEPA_SEQUENCE_TYPES.has(sequenceType)) {
return "Ungültige Mandatssequenz."
}
const [customer] = await server.db
.select()
.from(customers)
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
.limit(1)
if (!customer) {
return "Kunde nicht gefunden."
}
const [bankaccount] = await server.db
.select()
.from(entitybankaccounts)
.where(and(eq(entitybankaccounts.id, bankaccountId), eq(entitybankaccounts.tenant, tenantId)))
.limit(1)
if (!bankaccount) {
return "Bankverbindung nicht gefunden."
}
const assignedBankAccountIds = Array.isArray((customer.infoData as any)?.bankAccountIds)
? (customer.infoData as any).bankAccountIds.map((id: any) => Number(id))
: []
if (!assignedBankAccountIds.includes(bankaccountId)) {
return "Die Bankverbindung ist dem ausgewählten Kunden nicht zugeordnet."
}
return null
}
export default async function resourceRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
@@ -796,6 +858,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "outgoingsepamandates") {
const validationError = await validateOutgoingSepaMandatePayload(server, req.user.tenant_id, createData)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const numberRangeResource = resource === "members" ? "customers" : resource
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
@@ -809,6 +878,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
const [created] = await server.db.insert(table).values(createData).returning()
if (resource === "outgoingsepamandates" && created?.defaultMandate) {
await server.db
.update(table)
.set({ defaultMandate: false })
.where(and(
eq(table.tenant, req.user.tenant_id),
eq(table.customer, created.customer),
sql`${table.id} <> ${created.id}`
))
}
if (["products", "services", "hourrates"].includes(resource)) {
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
}
@@ -917,6 +997,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
}
if (resource === "outgoingsepamandates") {
const validationError = await validateOutgoingSepaMandatePayload(server, tenantId, data, oldRecord)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
Object.keys(data).forEach((key) => {
const value = data[key]
const shouldNormalize =
@@ -934,6 +1021,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
if (resource === "outgoingsepamandates" && updated?.defaultMandate) {
await server.db
.update(table)
.set({ defaultMandate: false })
.where(and(
eq(table.tenant, tenantId),
eq(table.customer, updated.customer),
sql`${table.id} <> ${updated.id}`
))
}
if (["products", "services", "hourrates"].includes(resource)) {
await recalculateServicePricesForTenant(server, tenantId, userId);
}

View File

@@ -1,6 +1,8 @@
import xmlbuilder from "xmlbuilder";
import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween.js";
import utc from "dayjs/plugin/utc.js";
import timezone from "dayjs/plugin/timezone.js";
import { BlobWriter, Data64URIReader, TextReader, ZipWriter } from "@zip.js/zip.js";
import { FastifyInstance } from "fastify";
import { GetObjectCommand } from "@aws-sdk/client-s3";
@@ -25,6 +27,8 @@ import {
} from "../../../db/schema";
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(timezone);
// ---------------------------------------------------------
// HELPER FUNCTIONS (Unverändert)
@@ -34,17 +38,28 @@ const getCreatedDocumentTotal = (item: any) => {
let totalNet = 0;
let total19:number = 0;
let total7:number = 0;
let net19 = 0;
let net7 = 0;
let net0 = 0;
const rows = Array.isArray(item.rows) ? item.rows : [];
rows.forEach((row: any) => {
if (!['pagebreak', 'title', 'text'].includes(row.mode)) {
const taxPercent = Number(row.taxPercent);
let rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)).toFixed(3);
totalNet = totalNet + Number(rowPrice);
if (row.taxPercent === 19) total19 += Number(rowPrice) * Number(0.19);
else if (row.taxPercent === 7) total7 += Number(rowPrice) * Number(0.07);
if (taxPercent === 19) {
net19 += Number(rowPrice);
total19 += Number(rowPrice) * Number(0.19);
} else if (taxPercent === 7) {
net7 += Number(rowPrice);
total7 += Number(rowPrice) * Number(0.07);
} else if (taxPercent === 0) {
net0 += Number(rowPrice);
}
}
});
return {
totalNet, total19, total7,
totalNet, total19, total7, net19, net7, net0,
totalGross: Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
};
};
@@ -57,12 +72,30 @@ const displayCurrency = (input: number, onlyAbs = false) => {
return (onlyAbs ? Math.abs(input) : input).toFixed(2).replace(".", ",");
};
const getCreatedDocumentDatevTaxKey = (document: { taxType?: string | null }) => {
return document.taxType === "13b UStG" ? "46" : "";
const DATEV_TIMEZONE = "Europe/Berlin";
const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
if (!date) return "";
const parsed = dayjs(date);
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : "";
};
const getCreatedDocumentPaymentDatevTaxKey = (document: { taxType?: string | null }) => {
return document.taxType === "13b UStG" ? "46" : "3";
const getCreatedDocumentRevenueLines = (document: any) => {
const totals = getCreatedDocumentTotal(document);
if (document.taxType === "13b UStG") {
return [{ account: "8337", amount: totals.totalGross }];
}
if (document.taxType === "19 UStG") {
return [{ account: "8192", amount: totals.totalGross }];
}
return [
{ account: "8400", amount: Number(totals.net19.toFixed(2)) },
{ account: "8334", amount: Number(totals.net7.toFixed(2)) },
{ account: "8290", amount: Number(totals.net0.toFixed(2)) },
].filter((line) => line.amount !== 0);
};
// ---------------------------------------------------------
@@ -84,8 +117,10 @@ export async function buildExportZip(
// Header Infos
const dateNowStr = dayjs().format("YYYYMMDDHHmmssSSS");
const startDateFmt = dayjs(startDate).format("YYYYMMDD");
const endDateFmt = dayjs(endDate).format("YYYYMMDD");
const startDateValue = formatDatevDate(startDate, "YYYY-MM-DD");
const endDateValue = formatDatevDate(endDate, "YYYY-MM-DD");
const startDateFmt = formatDatevDate(startDate, "YYYYMMDD");
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD");
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
@@ -107,8 +142,8 @@ export async function buildExportZip(
inArray(createddocuments.type, ["invoices", "advanceInvoices", "cancellationInvoices"]),
eq(createddocuments.state, "Gebucht"),
eq(createddocuments.archived, false),
gte(createddocuments.documentDate, startDate),
lte(createddocuments.documentDate, endDate)
gte(createddocuments.documentDate, startDateValue),
lte(createddocuments.documentDate, endDateValue)
));
// Mapping: Flat Result -> Nested Object (damit der Rest des Codes gleich bleiben kann)
@@ -129,8 +164,8 @@ export async function buildExportZip(
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.state, "Gebucht"),
eq(incominginvoices.archived, false),
gte(incominginvoices.date, startDate),
lte(incominginvoices.date, endDate)
gte(incominginvoices.date, startDateValue),
lte(incominginvoices.date, endDateValue)
));
const incominginvoicesList = iiRaw.map(r => ({
@@ -195,13 +230,13 @@ export async function buildExportZip(
eq(statementallocations.archived, false),
or(
and(
gte(bankstatements.date, startDate),
lte(bankstatements.date, endDate)
gte(bankstatements.date, startDateValue),
lte(bankstatements.date, endDateValue)
),
and(
isNull(statementallocations.bankstatement),
gte(statementallocations.manualBookingDate, startDate),
lte(statementallocations.manualBookingDate, endDate)
gte(statementallocations.manualBookingDate, startDateValue),
lte(statementallocations.manualBookingDate, endDateValue)
)
)
));
@@ -297,25 +332,23 @@ export async function buildExportZip(
// AR
createddocumentsList.forEach(cd => {
let file = filesCreateddocuments.find(i => i.createddocument === cd.id);
let total = 0;
let typeString = "";
if(cd.type === "invoices") {
total = getCreatedDocumentTotal(cd).totalGross;
typeString = "AR";
} else if(cd.type === "advanceInvoices") {
total = getCreatedDocumentTotal(cd).totalGross;
typeString = "ARAbschlag";
} else if(cd.type === "cancellationInvoices") {
total = getCreatedDocumentTotal(cd).totalGross;
typeString = "ARStorno";
}
let shSelector = Math.sign(total) === -1 ? "H" : "S";
const cust = cd.customer; // durch Mapping verfügbar
const datevTaxKey = getCreatedDocumentDatevTaxKey(cd);
const revenueLines = getCreatedDocumentRevenueLines(cd);
bookingLines.push(`${displayCurrency(total,true)};"${shSelector}";;;;;${cust?.customerNumber || ""};8400;"${datevTaxKey}";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`${typeString} ${cd.documentNumber} - ${cust?.name || ""}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${cust?.name || ""}";"Kundennummer";"${cust?.customerNumber || ""}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(cd.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
revenueLines.forEach((revenueLine) => {
let shSelector = Math.sign(revenueLine.amount) === -1 ? "H" : "S";
bookingLines.push(`${displayCurrency(revenueLine.amount,true)};"${shSelector}";;;;;${cust?.customerNumber || ""};${revenueLine.account};"";${formatDatevDate(cd.documentDate, "DDMM")};"${cd.documentNumber}";;;"${`${typeString} ${cd.documentNumber} - ${cust?.name || ""}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${cust?.name || ""}";"Kundennummer";"${cust?.customerNumber || ""}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${formatDatevDate(cd.deliveryDate, "DD.MM.YYYY")}";"Belegdatum";"${formatDatevDate(cd.documentDate, "DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
});
});
// ER
@@ -339,7 +372,7 @@ export async function buildExportZip(
let text = `ER ${ii.reference}: ${escapeString(ii.description)}`.substring(0,59);
const vend = ii.vendor; // durch Mapping verfügbar
bookingLines.push(`${Math.abs(amountGross).toFixed(2).replace(".",",")};"${shSelector}";;;;;${accountData.number};${vend?.vendorNumber || ""};"${buschluessel}";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${text}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${vend?.name || ""}";"Kundennummer";"${vend?.vendorNumber || ""}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
bookingLines.push(`${Math.abs(amountGross).toFixed(2).replace(".",",")};"${shSelector}";;;;;${accountData.number};${vend?.vendorNumber || ""};"${buschluessel}";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${text}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${vend?.name || ""}";"Kundennummer";"${vend?.vendorNumber || ""}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`);
});
});
@@ -373,8 +406,8 @@ export async function buildExportZip(
if(!bs && alloc.manualBookingDate) {
const debit = getManualBookingSide(alloc, "debit");
const credit = getManualBookingSide(alloc, "credit");
const dateManual = dayjs(alloc.manualBookingDate).format("DDMM");
const dateManualFull = dayjs(alloc.manualBookingDate).format("DD.MM.YYYY");
const dateManual = formatDatevDate(alloc.manualBookingDate, "DDMM");
const dateManualFull = formatDatevDate(alloc.manualBookingDate, "DD.MM.YYYY");
const belegnummer = debit.reference || credit.reference || "";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"${alloc.datevTaxKey || ""}";${dateManual};"${belegnummer}";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"${belegnummer}";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
return;
@@ -385,19 +418,18 @@ export async function buildExportZip(
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
// @ts-ignore
let datevKonto = bs.account?.datevNumber || "";
let dateVal = dayjs(bs.date).format("DDMM");
let dateFull = dayjs(bs.date).format("DD.MM.YYYY");
let dateVal = formatDatevDate(bs.date, "DDMM");
let dateFull = formatDatevDate(bs.date, "DD.MM.YYYY");
let bsText = escapeString(bs.text);
if(alloc.createddocument && alloc.createddocument.customer) {
const cd = alloc.createddocument;
const cust = cd.customer;
const datevTaxKey = getCreatedDocumentPaymentDatevTaxKey(cd);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"${datevTaxKey}";${dayjs(cd.documentDate).format("DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${dayjs(cd.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"H";;;;;${cust?.customerNumber};${datevKonto};"";${formatDatevDate(cd.documentDate, "DDMM")};"${cd.documentNumber}";;;"${`ZE${alloc.description}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust?.name}";"Kundennummer";"${cust?.customerNumber}";"Belegnummer";"${cd.documentNumber}";"Leistungsdatum";"${formatDatevDate(cd.deliveryDate, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.incominginvoice && alloc.incominginvoice.vendor) {
const ii = alloc.incominginvoice;
const vend = ii.vendor;
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${dayjs(ii.date).format("DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${dayjs(ii.date).format("DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.account) {
const acc = alloc.account;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";

View File

@@ -2,126 +2,267 @@ import xmlbuilder from "xmlbuilder";
import { randomUUID } from "node:crypto";
import dayjs from "dayjs";
import { and, eq, inArray } from "drizzle-orm";
import { createddocuments, tenants } from "../../../db/schema";
import {
bankaccounts,
createddocuments,
customers,
entitybankaccounts,
outgoingsepamandates,
tenants,
} from "../../../db/schema";
import { decrypt } from "../crypt";
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
const data = await server.db
.select()
.from(createddocuments)
.where(and(
eq(createddocuments.tenant, tenant_id),
inArray(createddocuments.id, idsToExport)
))
const getCreatedDocumentTotal = (item: any) => {
let totalNet = 0;
let total19 = 0;
let total7 = 0;
const tenantRows = await server.db
const rows = Array.isArray(item.rows) ? item.rows : [];
rows.forEach((row: any) => {
if (!["pagebreak", "title", "text"].includes(row.mode)) {
const rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)).toFixed(3);
totalNet += Number(rowPrice);
if (Number(row.taxPercent) === 19) {
total19 += Number(rowPrice) * 0.19;
} else if (Number(row.taxPercent) === 7) {
total7 += Number(rowPrice) * 0.07;
}
}
});
return Number((Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))).toFixed(2));
};
const cleanIban = (value: string | null | undefined) => (value || "").replace(/\s+/g, "").toUpperCase();
const cleanBic = (value: string | null | undefined) => (value || "").replace(/\s+/g, "").toUpperCase();
const formatAmount = (value: number) => value.toFixed(2);
const sanitizeText = (value: string | null | undefined, maxLength = 140) => {
return (value || "")
.replace(/[\n\r;]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, maxLength);
};
const getDecryptedEntityBankAccount = (row: typeof entitybankaccounts.$inferSelect) => ({
iban: cleanIban(decrypt(row.ibanEncrypted as any)),
bic: cleanBic(decrypt(row.bicEncrypted as any)),
bankName: decrypt(row.bankNameEncrypted as any),
});
const buildDirectDebitTransaction = (item: any) => {
const amount = getCreatedDocumentTotal(item.document);
if (amount <= 0) {
throw new Error(`Beleg ${item.document.documentNumber || item.document.id} hat keinen positiven Zahlungsbetrag.`);
}
const debtorBankAccount = getDecryptedEntityBankAccount(item.debtorBankAccount);
if (!debtorBankAccount.iban || !debtorBankAccount.bic) {
throw new Error(`Bankverbindung für Mandat ${item.mandate.reference} ist unvollständig.`);
}
if (!item.mandate.signedAt) {
throw new Error(`Mandat ${item.mandate.reference} hat kein Unterschriftsdatum.`);
}
return {
amount,
xml: {
PmtId: {
EndToEndId: sanitizeText(item.document.documentNumber || `Beleg-${item.document.id}`, 35),
},
InstdAmt: {
"@Ccy": "EUR",
"#text": formatAmount(amount),
},
DrctDbtTx: {
MndtRltdInf: {
MndtId: sanitizeText(item.mandate.reference, 35),
DtOfSgntr: dayjs(item.mandate.signedAt).format("YYYY-MM-DD"),
AmdmntInd: "false",
},
},
DbtrAgt: {
FinInstnId: {
BIC: debtorBankAccount.bic,
},
},
Dbtr: {
Nm: sanitizeText(item.customer.name, 70),
},
DbtrAcct: {
Id: {
IBAN: debtorBankAccount.iban,
},
},
RmtInf: {
Ustrd: sanitizeText(`Rechnung ${item.document.documentNumber || item.document.id}`),
},
},
};
};
export const createSEPAExport = async (
server: any,
idsToExport: number[],
tenantId: number,
creditorBankaccountId: number
) => {
if (!idsToExport.length) {
throw new Error("Es wurden keine Belege für den SEPA-Export ausgewählt.");
}
const [tenantData] = await server.db
.select()
.from(tenants)
.where(eq(tenants.id, tenant_id))
.limit(1)
const tenantData = tenantRows[0]
console.log(tenantData)
.where(eq(tenants.id, tenantId))
.limit(1);
console.log(data)
if (!tenantData?.creditorId) {
throw new Error("Für den Mandanten ist keine Gläubiger-ID hinterlegt.");
}
let transactions = []
const [creditorBankAccount] = await server.db
.select()
.from(bankaccounts)
.where(and(
eq(bankaccounts.id, creditorBankaccountId),
eq(bankaccounts.tenant, tenantId)
))
.limit(1);
let obj = {
if (!creditorBankAccount) {
throw new Error("Das ausgewählte Gläubigerkonto wurde nicht gefunden.");
}
const rows = await server.db
.select({
document: createddocuments,
customer: customers,
mandate: outgoingsepamandates,
debtorBankAccount: entitybankaccounts,
})
.from(createddocuments)
.innerJoin(customers, eq(createddocuments.customer, customers.id))
.innerJoin(outgoingsepamandates, eq(createddocuments.outgoingsepamandate, outgoingsepamandates.id))
.innerJoin(entitybankaccounts, eq(outgoingsepamandates.bankaccount, entitybankaccounts.id))
.where(and(
eq(createddocuments.tenant, tenantId),
eq(createddocuments.payment_type, "direct-debit"),
inArray(createddocuments.id, idsToExport)
));
if (rows.length !== idsToExport.length) {
throw new Error("Nicht alle ausgewählten Belege sind gültige SEPA-Lastschrift-Belege mit Mandat.");
}
const invalidMandate = rows.find((row) => row.mandate.tenant !== tenantId || row.mandate.status !== "Aktiv" || row.mandate.archived);
if (invalidMandate) {
throw new Error(`Mandat ${invalidMandate.mandate.reference} ist nicht aktiv oder gehört nicht zum Mandanten.`);
}
const transactions = rows.map((row) => ({
...row,
transaction: buildDirectDebitTransaction(row),
}));
const totalAmount = Number(transactions.reduce((sum, item) => sum + item.transaction.amount, 0).toFixed(2));
const messageId = randomUUID();
const collectionDate = dayjs().add(3, "days").format("YYYY-MM-DD");
const createdAt = dayjs().format("YYYY-MM-DDTHH:mm:ss");
const creditorIban = cleanIban(creditorBankAccount.iban);
const creditorBic = cleanBic(creditorBankAccount.bankId);
type SepaTransaction = (typeof transactions)[number];
const groupedTransactions: Record<string, SepaTransaction[]> = {};
transactions.forEach((item) => {
const key = `${item.mandate.mandateType || "CORE"}-${item.mandate.sequenceType || "RCUR"}`;
groupedTransactions[key] = groupedTransactions[key] || [];
groupedTransactions[key].push(item);
});
const paymentInformations = Object.entries(groupedTransactions).map(([key, items], index) => {
const groupTotal = Number(items.reduce((sum, item) => sum + item.transaction.amount, 0).toFixed(2));
const [mandateType, sequenceType] = key.split("-");
return {
PmtInfId: sanitizeText(`${messageId}-${index + 1}`, 35),
PmtMtd: "DD",
BtchBookg: "true",
NbOfTxs: items.length,
CtrlSum: formatAmount(groupTotal),
PmtTpInf: {
SvcLvl: {
Cd: "SEPA",
},
LclInstrm: {
Cd: mandateType,
},
SeqTp: sequenceType,
},
ReqdColltnDt: collectionDate,
Cdtr: {
Nm: sanitizeText(tenantData.name, 70),
},
CdtrAcct: {
Id: {
IBAN: creditorIban,
},
},
CdtrAgt: {
FinInstnId: {
BIC: creditorBic,
},
},
ChrgBr: "SLEV",
CdtrSchmeId: {
Id: {
PrvtId: {
Othr: {
Id: tenantData.creditorId,
SchmeNm: {
Prtry: "SEPA",
},
},
},
},
},
DrctDbtTxInf: items.map((item) => item.transaction.xml),
};
});
const obj = {
Document: {
'@xmlns':"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
'CstmrDrctDbtInitn': {
'GrpHdr': {
'MsgId': randomUUID(),
'CredDtTm': dayjs().format("YYYY-MM-DDTHH:mm:ss"),
'NbOfTxs': transactions.length,
'CtrlSum': 0, // TODO: Total Sum
'InitgPty': {
'Nm': tenantData.name
}
"@xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
CstmrDrctDbtInitn: {
GrpHdr: {
MsgId: sanitizeText(messageId, 35),
CreDtTm: createdAt,
NbOfTxs: transactions.length,
CtrlSum: formatAmount(totalAmount),
InitgPty: {
Nm: sanitizeText(tenantData.name, 70),
},
'PmtInf': {
'PmtInfId': "", // TODO: Mandatsreferenz,
'PmtMtd': "DD",
'BtchBookg': "true", // TODO: BatchBooking,
'NbOfTxs': transactions.length,
'CtrlSum': 0, //TODO: Total Sum
'PmtTpInf': {
'SvcLvl': {
'Cd': "SEPA"
},
'LclInstrm': {
'Cd': "CORE" // Core für BASIS / B2B für Firmen
PmtInf: paymentInformations,
},
'SeqTp': "RCUR" // TODO: Unterscheidung Einmalig / Wiederkehrend
},
'ReqdColltnDt': dayjs().add(3, "days").format("YYYY-MM-DDTHH:mm:ss"),
'Cdtr': {
'Nm': tenantData.name
},
'CdtrAcct': {
'Id': {
'IBAN': "DE" // TODO: IBAN Gläubiger EINSETZEN
}
},
'CdtrAgt': {
'FinInstnId': {
'BIC': "BIC" // TODO: BIC des Gläubigers einsetzen
}
},
'ChrgBr': "SLEV",
'CdtrSchmeId': {
'Id': {
'PrvtId': {
'Othr': {
'Id': tenantData.creditorId,
'SchmeNm': {
'Prty': "SEPA"
}
}
}
}
},
//TODO ITERATE ALL INVOICES HERE
'DrctDbtTxInf': {
'PmtId': {
'EndToEndId': "" // TODO: EINDEUTIGE ReFERENZ wahrscheinlich RE Nummer
},
'InstdAmt': {
'@Ccy':"EUR",
'#text':100 //TODO: Rechnungssumme zwei NK mit Punkt
},
'DrctDbtTx': {
'MndtRltdInf': {
'MndtId': "", // TODO: Mandatsref,
'DtOfSgntr': "", //TODO: Unterschrieben am,
'AmdmntInd': "" //TODO: Mandat geändert
}
},
'DbtrAgt': {
'FinInstnId': {
'BIC': "", //TODO: BIC Debtor
}
},
'Dbtr': {
'Nm': "" // TODO NAME Debtor
},
'DbtrAcct': {
'Id': {
'IBAN': "DE" // TODO IBAN Debtor
}
},
'RmtInf': {
'Ustrd': "" //TODO Verwendungszweck Rechnungsnummer
}
}
}
}
};
}
}
const xml = xmlbuilder.create(obj, { encoding: "UTF-8", standalone: true }).end({ pretty: true });
const documentDates = rows.map((row) => dayjs(row.document.documentDate)).filter((date) => date.isValid());
const startDate = documentDates.reduce((min, date) => date.isBefore(min) ? date : min, documentDates[0] || dayjs());
const endDate = documentDates.reduce((max, date) => date.isAfter(max) ? date : max, documentDates[0] || dayjs());
let doc = xmlbuilder.create(obj, {encoding: 'UTF-8', standalone: true})
console.log(doc.end({pretty:true}))
}
return {
buffer: Buffer.from(xml, "utf-8"),
startDate: startDate.toDate(),
endDate: endDate.toDate(),
count: transactions.length,
totalAmount,
};
};

View File

@@ -34,6 +34,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
files: "Dateien",
memberrelations: "Mitgliedsverhältnisse",
teams: "Teams",
outgoingsepamandates: "Ausgehende SEPA-Mandate",
}
export function getHistoryEntityLabel(entity: string) {
@@ -94,6 +95,7 @@ export async function insertHistoryItem(
incominginvoices: "incomingInvoice",
files: "file",
memberrelations: "memberrelation",
outgoingsepamandates: "outgoingsepamandate",
}
const fkColumn = columnMap[params.entity]

View File

@@ -25,6 +25,7 @@ import {
letterheads,
memberrelations,
ownaccounts,
outgoingsepamandates,
plants,
productcategories,
products,
@@ -53,7 +54,7 @@ export const resourceConfig = {
},
customers: {
searchColumns: ["name", "nameAddition", "customerNumber", "firstname", "lastname", "notes"],
mtmLoad: ["contacts","projects","plants","createddocuments","contracts","customerinventoryitems","customerspaces"],
mtmLoad: ["contacts","projects","plants","createddocuments","contracts","outgoingsepamandates","customerinventoryitems","customerspaces"],
table: customers,
numberRangeHolder: "customerNumber",
},
@@ -77,7 +78,13 @@ export const resourceConfig = {
table: contracts,
searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
numberRangeHolder: "contractNumber",
mtoLoad: ["customer", "contracttype"],
mtoLoad: ["customer", "contracttype", "outgoingsepamandate"],
},
outgoingsepamandates: {
table: outgoingsepamandates,
searchColumns: ["reference", "status", "mandateType", "sequenceType", "notes"],
numberRangeHolder: "reference",
mtoLoad: ["customer", "bankaccount"],
},
contracttypes: {
table: contracttypes,
@@ -200,7 +207,7 @@ export const resourceConfig = {
},
createddocuments: {
table: createddocuments,
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument", "outgoingsepamandate"],
mtmLoad: ["statementallocations","files","createddocuments"],
mtmListLoad: ["statementallocations", "files"],
},
@@ -235,6 +242,10 @@ export const resourceConfig = {
table: entitybankaccounts,
searchColumns: ["description"],
},
bankaccount: {
table: entitybankaccounts,
searchColumns: ["description"],
},
serialexecutions: {
table: serialExecutions
}

View File

@@ -38,9 +38,97 @@ export let secrets = {
DOKUBOX_IMAP_PASSWORD: string
OPENAI_API_KEY: string
STIRLING_API_KEY: string
MATRIX_HOMESERVER_URL?: string
MATRIX_SERVER_NAME?: string
MATRIX_RTC_HOST?: string
MATRIX_RTC_JWT_URL?: string
MATRIX_LIVEKIT_URL?: string
MATRIX_REGISTRATION_SHARED_SECRET?: string
MATRIX_SERVICE_USER_LOCALPART?: string
LIVEKIT_KEY?: string
LIVEKIT_SECRET?: string
WEB_PUSH_PUBLIC_KEY?: string
WEB_PUSH_PRIVATE_KEY?: string
WEB_PUSH_SUBJECT?: string
}
const secretKeys = [
"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",
"MATRIX_HOMESERVER_URL",
"MATRIX_SERVER_NAME",
"MATRIX_RTC_HOST",
"MATRIX_RTC_JWT_URL",
"MATRIX_LIVEKIT_URL",
"MATRIX_REGISTRATION_SHARED_SECRET",
"MATRIX_SERVICE_USER_LOCALPART",
"LIVEKIT_KEY",
"LIVEKIT_SECRET",
"WEB_PUSH_PUBLIC_KEY",
"WEB_PUSH_PRIVATE_KEY",
"WEB_PUSH_SUBJECT",
] as const
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE"])
function normalizeEnvValue(key: string, value: string) {
if (numberKeys.has(key)) return Number(value)
if (booleanKeys.has(key)) return value === "true"
return value
}
function loadSecretsFromEnv() {
let loaded = 0
secretKeys.forEach((key) => {
const value = process.env[key]
if (value === undefined || value === "") return
;(secrets as Record<string, any>)[key] = normalizeEnvValue(key, value)
loaded++
})
if (!secrets.HOST) secrets.HOST = "0.0.0.0"
if (!secrets.PORT) secrets.PORT = 3100
return loaded
}
export async function loadSecrets () {
const envSecretCount = loadSecretsFromEnv()
if (!process.env.INFISICAL_CLIENT_ID || !process.env.INFISICAL_CLIENT_SECRET) {
console.log(`✅ Secrets aus Umgebungsvariablen geladen (${envSecretCount} Stück)`)
return
}
await client.auth().universalAuth.login({
clientId: process.env.INFISICAL_CLIENT_ID,
@@ -53,9 +141,9 @@ export async function loadSecrets () {
});
allSecrets.secrets.forEach(secret => {
secrets[secret.secretKey] = secret.secretValue
;(secrets as Record<string, any>)[secret.secretKey] = normalizeEnvValue(secret.secretKey, secret.secretValue)
})
console.log("✅ Secrets aus Infisical geladen");
loadSecretsFromEnv()
console.log("✅ Secrets aus Infisical und Umgebungsvariablen geladen");
console.log(Object.keys(secrets).length + " Stück")
}

170
docker-compose.selfhost.yml Normal file
View File

@@ -0,0 +1,170 @@
services:
traefik:
image: traefik:v2.11
container_name: fedeo-traefik
restart: unless-stopped
command:
- --api.insecure=false
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
- --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --accesslog=true
- --accesslog.filepath=/logs/access.log
ports:
- "80:80"
- "443:443"
volumes:
- ./traefik/letsencrypt:/letsencrypt
- ./traefik/logs:/logs
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- web
db:
image: postgres:16
container_name: fedeo-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- internal
minio:
image: minio/minio:latest
container_name: fedeo-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- ./minio:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 10
networks:
- internal
createbuckets:
image: minio/mc:latest
container_name: fedeo-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
mc mb --ignore-existing local/${MINIO_BUCKET};
mc anonymous set private local/${MINIO_BUCKET};
exit 0;
"
restart: "no"
networks:
- internal
backend:
build:
context: ./backend
container_name: fedeo-backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
createbuckets:
condition: service_completed_successfully
environment:
NODE_ENV: production
FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true}
HOST: ${HOST:-0.0.0.0}
PORT: ${PORT:-3100}
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}
FEDEO_BOOTSTRAP_ADMIN_EMAIL: ${FEDEO_BOOTSTRAP_ADMIN_EMAIL:-}
FEDEO_BOOTSTRAP_ADMIN_PASSWORD: ${FEDEO_BOOTSTRAP_ADMIN_PASSWORD:-}
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME:-Admin}
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer}
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
- traefik.http.routers.fedeo-backend.entrypoints=websecure
- traefik.http.routers.fedeo-backend.tls.certresolver=letsencrypt
- traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend
- traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip
- traefik.http.services.fedeo-backend.loadbalancer.server.port=3100
networks:
- web
- internal
frontend:
build:
context: ./frontend
container_name: fedeo-frontend
restart: unless-stopped
depends_on:
- backend
environment:
NODE_ENV: production
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
NUXT_PUBLIC_MATRIX_ELEMENT_URL: ${NUXT_PUBLIC_MATRIX_ELEMENT_URL:-}
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
- traefik.http.routers.fedeo-frontend.entrypoints=websecure
- traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt
- traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000
networks:
- web
networks:
web:
driver: bridge
internal:
driver: bridge

View File

@@ -53,6 +53,9 @@ services:
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
- INFISICAL_CLIENT_SECRET=4e3441acc0adbffd324aa50e668a95a556a3f55ec6bb85954e176e35a3392003
- NODE_ENV=production
- WEB_PUSH_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-}
- WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-}
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
networks:
- traefik
labels:
@@ -71,6 +74,311 @@ services:
- "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"
matrix-db:
image: postgres:16-alpine
restart: unless-stopped
profiles:
- matrix
environment:
- POSTGRES_DB=${MATRIX_POSTGRES_DB:-synapse}
- POSTGRES_USER=${MATRIX_POSTGRES_USER:-synapse}
- POSTGRES_PASSWORD=${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password}
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --lc-collate=C --lc-ctype=C
volumes:
- ./matrix/postgres:/var/lib/postgresql/data
networks:
- traefik
matrix-redis:
image: redis:7-alpine
restart: unless-stopped
profiles:
- matrix
networks:
- traefik
matrix-synapse:
image: ghcr.io/element-hq/synapse:latest
restart: unless-stopped
profiles:
- matrix
depends_on:
- matrix-db
- matrix-redis
environment:
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
volumes:
- ./matrix/synapse:/data
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=8008"
- "traefik.http.services.fedeo-matrix.loadbalancer.server.port=8008"
# Matrix Client-Server API
- "traefik.http.routers.fedeo-matrix.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix`)"
- "traefik.http.routers.fedeo-matrix.entrypoints=web"
- "traefik.http.routers.fedeo-matrix.middlewares=fedeo-matrix-redirect-web-secure"
- "traefik.http.routers.fedeo-matrix.service=fedeo-matrix"
- "traefik.http.middlewares.fedeo-matrix-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.routers.fedeo-matrix-secure.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix`)"
- "traefik.http.routers.fedeo-matrix-secure.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-secure.service=fedeo-matrix"
# Matrix Federation API, nur öffnen wenn Federation gewünscht ist.
- "traefik.http.routers.fedeo-matrix-federation.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix/federation`)"
- "traefik.http.routers.fedeo-matrix-federation.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-federation.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-federation.service=fedeo-matrix"
matrix-well-known:
image: nginx:1.27-alpine
restart: unless-stopped
profiles:
- matrix
volumes:
- ./matrix/well-known:/usr/share/nginx/html/.well-known/matrix:ro
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=80"
- "traefik.http.services.fedeo-matrix-well-known.loadbalancer.server.port=80"
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolalloworiginlist=*"
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowmethods=GET,OPTIONS"
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowheaders=Content-Type,Authorization"
- "traefik.http.routers.fedeo-matrix-well-known.rule=Host(`${MATRIX_SERVER_NAME:-fedeo.de}`) && PathPrefix(`/.well-known/matrix`)"
- "traefik.http.routers.fedeo-matrix-well-known.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-well-known.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-well-known.middlewares=fedeo-matrix-well-known-cors"
- "traefik.http.routers.fedeo-matrix-well-known.service=fedeo-matrix-well-known"
matrix-turn:
image: instrumentisto/coturn:4
restart: unless-stopped
profiles:
- matrix
command:
- --fingerprint
- --use-auth-secret
- --static-auth-secret=${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret}
- --realm=${MATRIX_SERVER_NAME:-fedeo.de}
- --listening-port=3478
- --tls-listening-port=5349
- --min-port=49160
- --max-port=49200
- --no-cli
- --no-tlsv1
- --no-tlsv1_1
ports:
- "3478:3478/tcp"
- "3478:3478/udp"
- "5349:5349/tcp"
- "49160-49200:49160-49200/udp"
networks:
- traefik
matrix-livekit:
image: livekit/livekit-server:v1.9
restart: unless-stopped
profiles:
- matrix
depends_on:
- matrix-redis
entrypoint: /bin/sh
command:
- -ec
- |
cat >/tmp/livekit.yaml <<EOF
port: 7880
redis:
address: matrix-redis:6379
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
keys:
${LIVEKIT_KEY:-fedeo-livekit}: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
room:
auto_create: true
EOF
exec /livekit-server --config /tmp/livekit.yaml
ports:
- "7881:7881/tcp"
- "50000-50100:50000-50100/udp"
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=7880"
- "traefik.http.services.fedeo-matrix-livekit.loadbalancer.server.port=7880"
- "traefik.http.middlewares.fedeo-matrix-livekit-strip.stripprefix.prefixes=/livekit/sfu"
- "traefik.http.routers.fedeo-matrix-livekit.rule=Host(`${MATRIX_RTC_HOST:-call.fedeo.de}`) && PathPrefix(`/livekit/sfu`)"
- "traefik.http.routers.fedeo-matrix-livekit.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-livekit.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-livekit.middlewares=fedeo-matrix-livekit-strip"
- "traefik.http.routers.fedeo-matrix-livekit.service=fedeo-matrix-livekit"
matrix-rtc-jwt:
image: ghcr.io/element-hq/lk-jwt-service:latest
restart: unless-stopped
profiles:
- matrix
depends_on:
- matrix-livekit
- matrix-synapse
environment:
- LIVEKIT_URL=wss://${MATRIX_RTC_HOST:-call.fedeo.de}/livekit/sfu
- LIVEKIT_KEY=${LIVEKIT_KEY:-fedeo-livekit}
- LIVEKIT_SECRET=${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
- LIVEKIT_FULL_ACCESS_HOMESERVERS=${MATRIX_SERVER_NAME:-fedeo.de}
- LIVEKIT_JWT_BIND=:8080
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=8080"
- "traefik.http.services.fedeo-matrix-rtc-jwt.loadbalancer.server.port=8080"
- "traefik.http.middlewares.fedeo-matrix-rtc-jwt-strip.stripprefix.prefixes=/livekit/jwt"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.rule=Host(`${MATRIX_RTC_HOST:-call.fedeo.de}`) && PathPrefix(`/livekit/jwt`)"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.middlewares=fedeo-matrix-rtc-jwt-strip"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.service=fedeo-matrix-rtc-jwt"
matrix-dev-db:
image: postgres:16-alpine
restart: unless-stopped
profiles:
- matrix-dev
environment:
- POSTGRES_DB=synapse
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=synapse-dev-password
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --lc-collate=C --lc-ctype=C
volumes:
- ./matrix/dev/postgres:/var/lib/postgresql/data
networks:
- traefik
matrix-dev-redis:
image: redis:7-alpine
restart: unless-stopped
profiles:
- matrix-dev
networks:
- traefik
matrix-dev-synapse:
image: ghcr.io/element-hq/synapse:latest
restart: unless-stopped
profiles:
- matrix-dev
depends_on:
- matrix-dev-db
- matrix-dev-redis
environment:
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
volumes:
- ./matrix/dev/synapse:/data
ports:
- "${MATRIX_DEV_SYNAPSE_PORT:-8008}:8008"
networks:
- traefik
matrix-dev-turn:
image: instrumentisto/coturn:4
restart: unless-stopped
profiles:
- matrix-dev
command:
- --fingerprint
- --use-auth-secret
- --static-auth-secret=matrix-dev-turn-secret
- --realm=localhost
- --listening-port=3478
- --min-port=49160
- --max-port=49200
- --no-cli
- --no-tls
- --no-dtls
ports:
- "${MATRIX_DEV_TURN_PORT:-3478}:3478/tcp"
- "${MATRIX_DEV_TURN_PORT:-3478}:3478/udp"
- "${MATRIX_DEV_TURN_MIN_PORT:-49160}-${MATRIX_DEV_TURN_MAX_PORT:-49200}:49160-49200/udp"
networks:
- traefik
matrix-dev-livekit:
image: livekit/livekit-server:v1.9
restart: unless-stopped
profiles:
- matrix-dev
depends_on:
- matrix-dev-redis
entrypoint: /bin/sh
command:
- -ec
- |
cat >/tmp/livekit.yaml <<EOF
port: 7880
redis:
address: matrix-dev-redis:6379
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
node_ip: ${MATRIX_DEV_LIVEKIT_NODE_IP:-127.0.0.1}
use_external_ip: false
enable_loopback_candidate: true
keys:
devkey: devsecret-local-matrix-stack-32-chars
room:
auto_create: true
EOF
exec /livekit-server --config /tmp/livekit.yaml
ports:
- "${MATRIX_DEV_LIVEKIT_PORT:-7880}:7880"
- "${MATRIX_DEV_LIVEKIT_TCP_PORT:-7881}:7881/tcp"
- "${MATRIX_DEV_LIVEKIT_RTC_MIN_PORT:-50000}-${MATRIX_DEV_LIVEKIT_RTC_MAX_PORT:-50100}:50000-50100/udp"
networks:
- traefik
matrix-dev-rtc-jwt:
image: ghcr.io/element-hq/lk-jwt-service:latest
restart: unless-stopped
profiles:
- matrix-dev
depends_on:
- matrix-dev-livekit
- matrix-dev-synapse
environment:
- LIVEKIT_URL=ws://localhost:${MATRIX_DEV_LIVEKIT_PORT:-7880}
- LIVEKIT_KEY=devkey
- LIVEKIT_SECRET=devsecret-local-matrix-stack-32-chars
- LIVEKIT_FULL_ACCESS_HOMESERVERS=localhost
- LIVEKIT_JWT_BIND=:8080
ports:
- "${MATRIX_DEV_RTC_JWT_PORT:-8081}:8080"
networks:
- traefik
matrix-dev-element:
image: vectorim/element-web:latest
restart: unless-stopped
profiles:
- matrix-dev
volumes:
- ./matrix/dev/element-config.json:/app/config.json:ro
ports:
- "${MATRIX_DEV_ELEMENT_PORT:-8080}:80"
networks:
- traefik
# db:
# image: postgres
# restart: always

View File

@@ -5,3 +5,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Einstieg
- [Bedienung](./bedienung/README.md)
- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md)

View File

@@ -0,0 +1,371 @@
# Kommunikationslösung auf Basis des Matrix-Standards
Dieser Entwurf beschreibt eine FEDEO-Kommunikationslösung für Chat, Anrufe und Videokonferenzen auf Basis des Matrix-Standards. Ziel ist eine souverän betreibbare Lösung, die Mandantenfähigkeit, Datenschutz, Rechteverwaltung und die bestehenden FEDEO-Workflows berücksichtigt.
## Zielbild
FEDEO erhält einen integrierten Kommunikationsbereich, der interne Zusammenarbeit und externe Kommunikation abdeckt:
- Chat in Einzel-, Gruppen-, Projekt-, Vorgangs- und Kundenräumen
- Audioanrufe aus Direktchats, Gruppenräumen und Kontakten
- Videokonferenzen mit Bildschirmfreigabe und Einladungslinks
- Ende-zu-Ende-verschlüsselte private Kommunikation
- revisionsfähige Verknüpfung von relevanten Kommunikationsereignissen mit FEDEO-Objekten
- optional föderierte Kommunikation mit externen Matrix-Organisationen
Matrix wird dabei nicht als isolierter Messenger betrieben, sondern als Kommunikationsschicht neben dem bestehenden FEDEO-Backend.
## Empfohlene Architektur
```text
Nutzerinnen und Nutzer
|
| FEDEO Web, Mobile App, optional Element Desktop/Mobile
v
FEDEO Frontend
|
| FEDEO API, SSO, Rechte, Objektkontext
v
FEDEO Backend
|
| Provisionierung, Webhooks, Audit-Metadaten
v
Matrix Homeserver
|
+-- PostgreSQL für Matrix-Daten
+-- Redis für Worker und Caches
+-- Medien-Repository für Anhänge
+-- TURN/STUN für direkte Medienverbindungen
+-- MatrixRTC / LiveKit SFU für Gruppenanrufe und Videokonferenzen
```
### Kernkomponenten
| Komponente | Empfehlung | Aufgabe |
| --- | --- | --- |
| Matrix Homeserver | Synapse | Standardnaher, bewährter Homeserver mit guter Betriebsdokumentation |
| Matrix Client im FEDEO Web | Matrix JS SDK oder eingebetteter Element-Web-Ausschnitt | Chat, Raumliste, Nachrichten, Reaktionen, Anhänge |
| Mobile Integration | Matrix SDK über FEDEO Mobile oder Deep Link zu Element X | Pushfähige mobile Kommunikation |
| Identität | OIDC/SSO über FEDEO Auth, perspektivisch Matrix Authentication Service | Einheitlicher Login und zentrale Nutzerverwaltung |
| Audio/Video | MatrixRTC mit Element Call und LiveKit SFU | Moderne Anrufe und Videokonferenzen |
| NAT Traversal | coturn | STUN/TURN für stabile Medienverbindungen |
| Reverse Proxy | bestehender Traefik-Ansatz | TLS, Routing, `.well-known/matrix/*` |
| Administration | FEDEO Admin-Oberfläche plus Synapse Admin API | Nutzer, Räume, Richtlinien, Sperren |
## Betriebsmodell
Für FEDEO ist ein eigener Matrix-Homeserver pro Installation oder pro großer Betreiberinstanz sinnvoll. Der Matrix-Server sollte nicht öffentlich als offener Registrierungsserver betrieben werden. Nutzer werden ausschließlich durch FEDEO angelegt, aktualisiert und deaktiviert.
Empfohlene Domains:
- `app.example.com`: FEDEO Oberfläche
- `matrix.example.com`: Matrix Client-Server und Federation API
- `call.example.com`: Element Call / MatrixRTC
- `livekit.example.com`: LiveKit SFU
- `turn.example.com`: TURN/STUN
Die öffentliche Matrix-Serverkennung kann trotzdem `example.com` lauten. Dafür werden `.well-known/matrix/client` und `.well-known/matrix/server` über Traefik ausgeliefert.
## Mandantenmodell
Matrix selbst ist raumbasiert, FEDEO ist mandantenbasiert. Deshalb sollte FEDEO die Mandantenlogik explizit auf Matrix-Räume und Spaces abbilden.
### Räume und Spaces
- Pro FEDEO-Mandant wird ein Matrix Space angelegt.
- Projekte, Vorgänge, Helpdesk-Konversationen, interne Teams und Kundenkontakte werden als Räume im Mandanten-Space geführt.
- Direkträume werden nutzerbezogen angelegt, aber über FEDEO mandantengebunden sichtbar gemacht.
- Externe Räume erhalten einen klaren Status, zum Beispiel `intern`, `extern`, `kunde`, `lieferant`.
### Raumalias-Konvention
Beispiele:
- `#tenant-<mandant>-team:example.com`
- `#tenant-<mandant>-project-<projekt>:example.com`
- `#tenant-<mandant>-ticket-<ticket>:example.com`
- `#tenant-<mandant>-customer-<kunde>:example.com`
Interne technische IDs sollten nicht als sichtbarer Anzeigename genutzt werden. Nutzerinnen und Nutzer sehen sprechende Namen wie `Projekt: Website Relaunch` oder `Kunde: Muster GmbH`.
## Rechte und Rollen
FEDEO bleibt führend für Berechtigungen. Matrix übernimmt die technische Durchsetzung im Raum.
| FEDEO-Rolle | Matrix-Abbildung |
| --- | --- |
| Mandantenadmin | Space-Admin und Raumadmin |
| Teamleitung | Moderatorin oder Moderator in Team- und Projekträumen |
| Mitarbeitende | Mitglied mit Schreibrechten |
| Externe Kontakte | Eingeschränkte Mitgliedschaft in ausgewählten Räumen |
| Automationen | Application-Service- oder Bot-Nutzer mit minimalen Rechten |
Änderungen an Rollen, Teams oder Mandantenzugehörigkeiten lösen im FEDEO-Backend eine Synchronisation mit Matrix aus. Beim Entzug eines Zugriffs wird die Person aus den betroffenen Räumen entfernt. Bei Ende-zu-Ende-verschlüsselten Räumen muss zusätzlich berücksichtigt werden, dass bereits erhaltene Nachrichten auf Geräten verbleiben können.
## Chat
Der Chat wird als erste Ausbaustufe umgesetzt.
### Funktionen
- Direktnachrichten
- Gruppenräume
- Mandanten-, Team-, Projekt- und Vorgangsräume
- Datei- und Bildanhänge
- Erwähnungen, Reaktionen und Lesestatus
- Suche in nicht verschlüsselten Räumen über den Homeserver
- lokale Suche in verschlüsselten Räumen über Client-Indizes
- Verknüpfung von Nachrichten mit FEDEO-Objekten
### Integration in FEDEO
FEDEO sollte keine vollständige Kopie aller Nachrichten in der eigenen Datenbank speichern. Stattdessen speichert FEDEO nur Referenzen:
- Matrix Raum-ID
- Matrix Event-ID
- FEDEO Objekt-Typ und Objekt-ID
- Zeitstempel
- beteiligter FEDEO-Nutzer
- optionale Vorschau, falls Datenschutzrichtlinie dies erlaubt
So bleibt Matrix das Kommunikationssystem, während FEDEO nachvollziehen kann, welche Kommunikation zu welchem Objekt gehört.
## Audioanrufe
Einzelanrufe können direkt über Matrix-VoIP in Direktchats gestartet werden. Der FEDEO-Client zeigt dafür in Kontakt-, Kunden-, Mitarbeitenden- und Chatansichten einen Anruf-Button.
### Anforderungen
- WebRTC-Unterstützung im Browser
- STUN/TURN über coturn
- Geräteauswahl für Mikrofon und Lautsprecher
- Anrufbenachrichtigung im Web und mobil
- Statusanzeige `verfügbar`, `beschäftigt`, `im Anruf`, `abwesend`
Für klassische Telefonie kann später ein SIP-Gateway ergänzt werden. Das sollte jedoch getrennt von der ersten Matrix-Einführung betrachtet werden, damit Chat und WebRTC-Kommunikation nicht durch Telefoniekomplexität ausgebremst werden.
## Videokonferenzen
Für Gruppenanrufe und Videokonferenzen wird MatrixRTC mit Element Call und LiveKit empfohlen. Matrix übernimmt dabei Raumzustand, Identität, Berechtigungen und Signalisierung; LiveKit übernimmt als SFU die effiziente Medienverteilung.
### Funktionen
- Videokonferenzen aus Matrix-Räumen
- spontane Besprechungen aus Projekten, Vorgängen oder Kundenakten
- Bildschirmfreigabe
- Einladungslink für externe Gäste
- Wartebereich für externe Gäste
- Moderationsrechte für Stummschalten, Entfernen und Raumverwaltung
- optionale Aufzeichnung erst in einer späteren, gesondert freizugebenden Ausbaustufe
### Konfiguration
Clients finden den MatrixRTC-Dienst über `.well-known/matrix/client`. Dort wird der LiveKit-JWT-Dienst als `org.matrix.msc4143.rtc_foci` angekündigt. Diese Datei muss öffentlich lesbar sein, als JSON ausgeliefert werden und CORS für Webclients erlauben.
Beispiel:
```json
{
"m.homeserver": {
"base_url": "https://matrix.example.com"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://call.example.com/livekit/jwt"
}
]
}
```
## Authentifizierung und Nutzerverwaltung
FEDEO sollte Identität und Lebenszyklus der Nutzer zentral steuern.
### Empfohlener Ablauf
1. Nutzer wird in FEDEO angelegt.
2. FEDEO erzeugt oder aktualisiert den Matrix-Nutzer.
3. FEDEO weist den Nutzer den passenden Spaces und Räumen zu.
4. Login erfolgt über FEDEO SSO/OIDC.
5. Deaktivierung in FEDEO deaktiviert auch den Matrix-Zugang und entfernt Raumzugriffe.
Die Matrix User-ID sollte stabil und nicht personenbezogen änderungsanfällig sein:
```text
@u_<fedeo_user_id>:example.com
```
Der Anzeigename kann weiterhin den echten Namen enthalten und bei Änderungen synchronisiert werden.
## Datenschutz und Compliance
Matrix erlaubt starke Datenschutzkonzepte, erfordert aber klare Betriebsregeln.
### Empfehlungen
- Ende-zu-Ende-Verschlüsselung für Direktnachrichten und vertrauliche Projekträume aktivieren.
- Nicht verschlüsselte Räume nur dort nutzen, wo serverseitige Suche, Archivierung oder Compliance-Funktionen ausdrücklich benötigt werden.
- Medienaufbewahrung mandantenweit konfigurierbar machen.
- Externe Gäste optisch klar kennzeichnen.
- Federation standardmäßig deaktivieren oder auf erlaubte Domains beschränken.
- Aufzeichnungen von Videokonferenzen nur mit expliziter Einwilligung und sichtbarem Status erlauben.
- Administrative Zugriffe protokollieren.
- Klare Löschfristen für Räume, Anhänge und Audit-Referenzen definieren.
## Federation
Matrix kann mit anderen Homeservern föderieren. Für FEDEO sollte Federation als kontrollierbare Option umgesetzt werden.
### Betriebsmodi
| Modus | Beschreibung | Empfehlung |
| --- | --- | --- |
| geschlossen | Keine Federation, nur interne Nutzer und explizite Gäste | Standard für kleine Installationen |
| allowlist | Federation nur mit freigegebenen Domains | Empfehlung für B2B-Kommunikation |
| offen | Federation mit beliebigen Matrix-Servern | Nur für bewusst öffentliche Communities |
Für steuer-, kunden- und projektnahe Kommunikation ist `allowlist` der beste Zielmodus.
## Brücken zu anderen Systemen
Matrix unterstützt Brücken zu anderen Kommunikationsdiensten. Für FEDEO sind Brücken nützlich, sollten aber nicht zur ersten Produktstufe gehören.
Mögliche spätere Erweiterungen:
- E-Mail-Brücke für Helpdesk- oder Kundenkommunikation
- Slack- oder Teams-Brücke für externe Projektpartner
- WhatsApp- oder SMS-Brücke nur nach gesonderter Datenschutzprüfung
- SIP-Brücke für Telefonie
Brücken müssen pro Mandant aktivierbar sein und brauchen klare Hinweise, welche Daten an externe Dienste fließen.
## FEDEO-Produktoberfläche
Die Kommunikation sollte in FEDEO an zwei Stellen sichtbar sein.
### Globaler Kommunikationsbereich
- Raumliste
- Direktnachrichten
- Suche
- Anrufe
- laufende Besprechungen
- Benachrichtigungen
### Objektbezogene Kommunikation
In Projekten, Kunden, Vorgängen, Helpdesk-Tickets und Dokumenten erscheint ein Kommunikations-Tab:
- zugeordneter Raum
- relevante Nachrichtenreferenzen
- Start von Chat, Anruf oder Besprechung
- Teilnehmerverwaltung entsprechend FEDEO-Rechten
So bleibt Kommunikation dort, wo die Arbeit stattfindet.
## Backend-Integration
Das FEDEO-Backend erhält ein Kommunikationsmodul mit folgenden Aufgaben:
- Matrix-Nutzer provisionieren
- Spaces und Räume anlegen
- Raum-Mitgliedschaften synchronisieren
- Matrix-Event-Webhooks empfangen
- FEDEO-Objekte mit Matrix-Räumen verknüpfen
- Benachrichtigungseinstellungen verwalten
- Admin-Aktionen auditieren
Technisch kann dies über Matrix Admin API, Client-Server API und Application Services erfolgen. Für Automationen empfiehlt sich ein eigener Application Service, weil er reservierte Nutzer- und Raum-Namensräume sauber verwalten kann.
## Deployment-Erweiterung
Der bestehende Docker-/Traefik-Ansatz kann um folgende Dienste erweitert werden:
- `matrix-synapse`
- `matrix-db` oder gemeinsame PostgreSQL-Instanz mit getrennter Datenbank
- `redis`
- `coturn`
- `element-web` optional als Fallback-Client
- `element-call`
- `livekit`
- `matrix-rtc-jwt-service`
Für produktive Installationen sollte Matrix eine eigene PostgreSQL-Datenbank erhalten. Medien sollten in S3-kompatiblen Speicher ausgelagert werden, damit große Anhänge und Konferenzartefakte nicht den Applikationsserver füllen.
## Monitoring
Wichtige Kennzahlen:
- aktive Nutzerinnen und Nutzer
- Anzahl Räume pro Mandant
- Nachrichtenrate
- Medien-Speicherverbrauch
- Zustellverzögerung
- fehlgeschlagene Anrufe
- LiveKit Paketverlust, Latenz und Teilnehmerzahl
- TURN-Nutzung
- Federation-Fehler
Logs von FEDEO, Synapse, LiveKit, coturn und Traefik sollten über eine gemeinsame Korrelation, zum Beispiel Request-ID oder Nutzer-ID, untersuchbar sein.
## Risiken und Gegenmaßnahmen
| Risiko | Gegenmaßnahme |
| --- | --- |
| Komplexität durch zwei Systeme | FEDEO bleibt führend für Nutzer, Rechte und Objektbezug |
| Datenschutz bei externen Räumen | Externe Kennzeichnung, Federation-Allowlist, Mandantenrichtlinien |
| E2EE erschwert Suche und Archivierung | Raumtyp bewusst wählen, lokale Suche, Metadatenreferenzen statt Vollkopie |
| Medienverbindungen scheitern in Firmennetzen | coturn sauber betreiben, UDP und TCP/TLS-Fallback anbieten |
| Betriebskosten durch Video | LiveKit skalierbar betreiben, Limits pro Mandant definieren |
| Gästezugriff wird unübersichtlich | Einladungslinks mit Ablaufdatum, Wartebereich, Moderationsrechte |
## Umsetzung in Phasen
### Phase 1: Fundament und Chat
- Synapse mit PostgreSQL, Redis, Traefik und `.well-known` betreiben
- FEDEO-Nutzer zu Matrix synchronisieren
- Mandanten-Spaces und erste Teamräume anlegen
- Chat im FEDEO-Frontend integrieren
- Benachrichtigungen und Raumreferenzen speichern
### Phase 2: Objektbezogene Kommunikation
- Räume automatisch für Projekte, Vorgänge und Kunden anlegen
- Kommunikations-Tab in FEDEO-Objekten ergänzen
- Rechteänderungen aus FEDEO nach Matrix synchronisieren
- externe Gäste einladen und kennzeichnen
### Phase 3: Audio und Video
- coturn bereitstellen
- MatrixRTC, Element Call und LiveKit integrieren
- Anruf- und Videobuttons in Chat, Kontakten und Projekten ergänzen
- Gäste-Links und Wartebereich umsetzen
### Phase 4: Compliance und Skalierung
- Aufbewahrungsrichtlinien pro Mandant
- Monitoring und Admin-Dashboards
- Federation-Allowlist
- optionale Brücken
- optionale Aufzeichnung mit Einwilligungsworkflow
## Offene Entscheidungen
- Soll Federation initial deaktiviert oder direkt mit Allowlist ausgeliefert werden?
- Welche Räume müssen serverseitig durchsuchbar sein und bleiben deshalb unverschlüsselt?
- Sollen externe Gäste Matrix-Konten erhalten oder nur temporäre Konferenzzugänge?
- Wird Element als sichtbarer Fallback-Client angeboten oder soll alles primär in FEDEO stattfinden?
- Welche Mandantenlimits gelten für Speicher, Teilnehmerzahl und Videodauer?
## Quellen und Standards
- Matrix Specification: https://spec.matrix.org/
- Matrix Application Services: https://matrix.org/docs/older/application-services/
- Matrix Bridges: https://www.matrix.org/docs/communities/bridging/
- Synapse Worker-Dokumentation: https://matrix-org.github.io/synapse/develop/workers.html
- Element Call Self-Hosting: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md
- Element MatrixRTC Konfiguration: https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/
- LiveKit Self-Hosting: https://docs.livekit.io/transport/self-hosting/

View File

@@ -170,7 +170,7 @@ const setupQuery = () => {
Object.keys(data).forEach(key => {
if (dataType.templateColumns.find(i => i.key === key)) {
if (["customer", "contract", "plant", "contact", "project"].includes(key)) {
if (["customer", "contract", "plant", "contact", "project", "outgoingsepamandate", "bankaccount"].includes(key)) {
item.value[key] = Number(data[key])
} else {
item.value[key] = data[key]
@@ -349,6 +349,35 @@ const triggerInputChange = (datapoint) => {
}
}
const getDatapointValue = (datapoint) => {
if (datapoint.key.includes(".")) {
const [parentKey, childKey] = datapoint.key.split(".")
return item.value[parentKey]?.[childKey] || null
}
return item.value[datapoint.key] || null
}
const setDatapointValue = (datapoint, value) => {
if (datapoint.key.includes(".")) {
const [parentKey, childKey] = datapoint.key.split(".")
if (!item.value[parentKey]) item.value[parentKey] = {}
item.value[parentKey][childKey] = value
} else {
item.value[datapoint.key] = value
}
triggerInputChange(datapoint)
}
const getEntityModalCreateQuery = (datapoint) => {
if (datapoint.entityModalCreateQueryFunction) {
return datapoint.entityModalCreateQueryFunction(item.value)
}
return datapoint.entityModalCreateQuery || {}
}
const createItem = async () => {
let ret = null
@@ -595,6 +624,13 @@ const updateItem = async () => {
color="white"
icon="i-heroicons-x-mark"
/>
<EntityModalButtons
v-if="datapoint.entityModalButtonsType"
:type="datapoint.entityModalButtonsType"
:id="getDatapointValue(datapoint)"
:create-query="getEntityModalCreateQuery(datapoint)"
@return-data="(data) => setDatapointValue(datapoint, data.id)"
/>
</InputGroup>
<InputGroup class="w-full" v-else>
<UInput
@@ -708,6 +744,13 @@ const updateItem = async () => {
color="white"
icon="i-heroicons-x-mark"
/>
<EntityModalButtons
v-if="datapoint.entityModalButtonsType"
:type="datapoint.entityModalButtonsType"
:id="getDatapointValue(datapoint)"
:create-query="getEntityModalCreateQuery(datapoint)"
@return-data="(data) => setDatapointValue(datapoint, data.id)"
/>
</InputGroup>
</UFormField>
</div>
@@ -828,6 +871,13 @@ const updateItem = async () => {
color="white"
icon="i-heroicons-x-mark"
/>
<EntityModalButtons
v-if="datapoint.entityModalButtonsType"
:type="datapoint.entityModalButtonsType"
:id="getDatapointValue(datapoint)"
:create-query="getEntityModalCreateQuery(datapoint)"
@return-data="(data) => setDatapointValue(datapoint, data.id)"
/>
</InputGroup>
<InputGroup class="w-full" v-else>
<UInput
@@ -941,6 +991,13 @@ const updateItem = async () => {
color="white"
icon="i-heroicons-x-mark"
/>
<EntityModalButtons
v-if="datapoint.entityModalButtonsType"
:type="datapoint.entityModalButtonsType"
:id="getDatapointValue(datapoint)"
:create-query="getEntityModalCreateQuery(datapoint)"
@return-data="(data) => setDatapointValue(datapoint, data.id)"
/>
</InputGroup>
</UFormField>
</UForm>

View File

@@ -7,7 +7,7 @@ const props = defineProps({
required: true
},
id: {
type: String,
type: [String, Number],
},
createQuery: {
type: Object,

View File

@@ -75,6 +75,16 @@ const links = computed(() => {
]
const communicationChildren = [
{
label: "Chat",
to: "/communication/chat",
icon: "i-heroicons-chat-bubble-left-right"
},
{
label: "Matrix-Setup",
to: "/communication",
icon: "i-heroicons-cog-6-tooth"
},
featureEnabled("helpdesk") ? {
label: "Helpdesk",
to: "/helpdesk",
@@ -146,6 +156,11 @@ const links = computed(() => {
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("outgoingsepamandates") ? {
label: "SEPA-Mandate",
to: "/standardEntity/outgoingsepamandates",
icon: "i-heroicons-identification",
} : null,
(featureEnabled("incomingInvoices") || featureEnabled("banking")) ? {
label: "Abschreibungen",
to: "/accounting/depreciation",

View File

@@ -2,6 +2,8 @@
import {formatTimeAgo} from '@vueuse/core'
const { isNotificationsSlideoverOpen } = useDashboard()
const toast = useToast()
const desktopPush = useDesktopPush()
watch(isNotificationsSlideoverOpen, async (newVal,oldVal) => {
if(newVal === true) {
@@ -13,7 +15,8 @@ const notifications = ref([])
const setup = async () => {
try {
notifications.value = await useNuxtApp().$api("/api/resource/notifications_items")
notifications.value = await useNuxtApp().$api("/api/notifications")
await desktopPush.loadConfig()
} catch (e) {
notifications.value = []
}
@@ -23,9 +26,8 @@ setup()
const setNotificationAsRead = async (notification) => {
try {
await useNuxtApp().$api(`/api/resource/notifications_items/${notification.id}`, {
method: "PUT",
body: { readAt: new Date() }
await useNuxtApp().$api(`/api/notifications/${notification.id}/read`, {
method: "POST"
})
} catch (e) {
// noop: endpoint optional in older/newer backend variants
@@ -33,15 +35,78 @@ const setNotificationAsRead = async (notification) => {
setup()
}
const enableDesktopPush = async () => {
const success = await desktopPush.subscribe()
toast.add({
title: success ? "Desktop Push aktiviert" : "Desktop Push konnte nicht aktiviert werden",
description: desktopPush.error.value || undefined,
color: success ? "success" : "error"
})
}
const sendTestPush = async () => {
try {
await desktopPush.sendTestPush()
toast.add({ title: "Testbenachrichtigung gesendet", color: "success" })
await setup()
} catch (error) {
toast.add({
title: "Testbenachrichtigung fehlgeschlagen",
description: error?.data?.error || error?.message,
color: "error"
})
}
}
</script>
<template>
<USlideover v-model:open="isNotificationsSlideoverOpen" title="Benachrichtigungen" side="right">
<template #body>
<div class="mb-4 rounded-md border border-default bg-muted p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
Desktop Push
</p>
<p class="mt-1 text-xs text-muted">
<span v-if="!desktopPush.supported.value">Dieser Browser unterstützt Desktop Push nicht.</span>
<span v-else-if="!desktopPush.configured.value">Desktop Push ist im Backend noch nicht konfiguriert.</span>
<span v-else-if="desktopPush.permission.value === 'granted'">Benachrichtigungen sind im Browser erlaubt.</span>
<span v-else>Aktiviere Browser-Benachrichtigungen für FEDEO.</span>
</p>
</div>
<div class="flex shrink-0 gap-2">
<UButton
icon="i-heroicons-bell-alert"
size="xs"
color="primary"
variant="soft"
:loading="desktopPush.loading.value"
:disabled="!desktopPush.supported.value || !desktopPush.configured.value"
@click="enableDesktopPush"
>
Aktivieren
</UButton>
<UButton
icon="i-heroicons-paper-airplane"
size="xs"
color="neutral"
variant="outline"
:disabled="desktopPush.permission.value !== 'granted'"
@click="sendTestPush"
>
Test
</UButton>
</div>
</div>
</div>
<NuxtLink
v-for="notification in notifications"
:key="notification.id"
:to="notification.link"
:to="notification.payload?.link || notification.link || '/'"
class="p-3 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer flex items-center gap-3 relative"
@click="setNotificationAsRead(notification)"
>

View File

@@ -14,9 +14,7 @@ const tenantInitials = computed(() => {
.join('') || 'M'
})
const activeTenants = computed(() =>
auth.tenants.filter((tenant) => tenant.hasActiveLicense)
)
const activeTenants = computed(() => auth.tenants)
const tenantItems = computed(() => [
activeTenants.value.map((tenant) => ({

View File

@@ -0,0 +1,16 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span v-if="props.row.bankaccount">
{{ props.row.bankaccount.displayLabel || props.row.bankaccount.description || props.row.bankaccount.id }}
</span>
<span v-else>-</span>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
},
inShow: {
type: Boolean,
default: false
}
})
</script>
<template>
<div v-if="props.row.outgoingsepamandate">
<nuxt-link
v-if="props.inShow"
:to="`/standardEntity/outgoingsepamandates/show/${props.row.outgoingsepamandate.id}`"
>
{{ props.row.outgoingsepamandate.reference }}
</nuxt-link>
<span v-else>{{ props.row.outgoingsepamandate.reference }}</span>
</div>
<span v-else>-</span>
</template>

View File

@@ -0,0 +1,89 @@
const base64UrlToUint8Array = (value) => {
const padding = "=".repeat((4 - value.length % 4) % 4)
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/")
const rawData = window.atob(base64)
const output = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; i += 1) {
output[i] = rawData.charCodeAt(i)
}
return output
}
export const useDesktopPush = () => {
const { $api } = useNuxtApp()
const supported = computed(() =>
process.client &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
)
const permission = ref(process.client && "Notification" in window ? Notification.permission : "default")
const configured = ref(false)
const loading = ref(false)
const error = ref("")
const loadConfig = async () => {
if (!supported.value) return { configured: false, publicKey: "" }
const config = await $api("/api/notifications/push/config")
configured.value = Boolean(config.configured && config.publicKey)
return config
}
const subscribe = async () => {
error.value = ""
loading.value = true
try {
const config = await loadConfig()
if (!configured.value) {
throw new Error("Desktop Push ist im Backend noch nicht konfiguriert.")
}
const nextPermission = await Notification.requestPermission()
permission.value = nextPermission
if (nextPermission !== "granted") {
throw new Error("Benachrichtigungen wurden im Browser nicht erlaubt.")
}
const registration = await navigator.serviceWorker.register("/fedeo-push-sw.js")
const existingSubscription = await registration.pushManager.getSubscription()
const subscription = existingSubscription || await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToUint8Array(config.publicKey),
})
await $api("/api/notifications/push/subscribe", {
method: "POST",
body: subscription.toJSON(),
})
return true
} catch (err) {
error.value = err?.data?.error || err?.message || "Desktop Push konnte nicht aktiviert werden."
return false
} finally {
loading.value = false
}
}
const sendTestPush = async () => {
return await $api("/api/notifications/test-push", {
method: "POST",
})
}
return {
supported,
permission,
configured,
loading,
error,
loadConfig,
subscribe,
sendTestPush,
}
}

View File

@@ -80,7 +80,8 @@ export default defineNuxtConfig({
public: {
apiBase: '',
pdfLicense: ''
pdfLicense: '',
matrixElementUrl: process.env.NUXT_PUBLIC_MATRIX_ELEMENT_URL || 'http://localhost:8080'
}
},

View File

@@ -74,6 +74,7 @@
"image-js": "^1.1.0",
"leaflet": "^1.9.4",
"license-checker": "^25.0.1",
"livekit-client": "^2.19.0",
"maplibre-gl": "^4.7.0",
"nuxt-editorjs": "^1.0.4",
"nuxt-viewport": "^2.0.6",
@@ -1865,6 +1866,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@capacitor-community/bluetooth-le": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.3.0.tgz",
@@ -3159,6 +3166,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.45.8",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
@@ -8696,6 +8718,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -12191,7 +12220,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
@@ -14209,6 +14237,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
@@ -14784,6 +14821,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/livekit-client": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.45.8",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.5"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/local-pkg": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
@@ -14858,6 +14915,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -18452,6 +18522,16 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -18609,6 +18689,21 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/sdp": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
@@ -20164,6 +20259,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -21718,6 +21822,19 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/webrtc-adapter": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@@ -87,6 +87,7 @@
"image-js": "^1.1.0",
"leaflet": "^1.9.4",
"license-checker": "^25.0.1",
"livekit-client": "^2.19.0",
"maplibre-gl": "^4.7.0",
"nuxt-editorjs": "^1.0.4",
"nuxt-viewport": "^2.0.6",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,501 @@
<script setup>
const toast = useToast()
const { $api } = useNuxtApp()
const status = ref(null)
const identity = ref(null)
const tenantSpace = ref(null)
const generalRoom = ref(null)
const provisionResult = ref(null)
const tenantSpaceProvisionResult = ref(null)
const generalRoomProvisionResult = ref(null)
const loading = ref(false)
const provisioning = ref(false)
const tenantSpaceProvisioning = ref(false)
const generalRoomProvisioning = ref(false)
const lastUpdated = ref(null)
const statusItems = computed(() => [
{
label: "Konfiguration",
value: status.value?.configured ? "Aktiv" : "Nicht aktiv",
icon: status.value?.configured ? "i-heroicons-check-circle" : "i-heroicons-x-circle",
color: status.value?.configured ? "success" : "error"
},
{
label: "Homeserver",
value: status.value?.homeserverUrl || "-",
icon: "i-heroicons-server-stack",
color: "neutral"
},
{
label: "Servername",
value: status.value?.serverName || "-",
icon: "i-heroicons-identification",
color: "neutral"
},
{
label: "Provisionierung",
value: status.value?.provisioningConfigured ? "Bereit" : "Nicht eingerichtet",
icon: status.value?.provisioningConfigured ? "i-heroicons-key" : "i-heroicons-exclamation-triangle",
color: status.value?.provisioningConfigured ? "success" : "warning"
},
{
label: "Erreichbarkeit",
value: status.value?.reachable ? "Erreichbar" : "Nicht erreichbar",
icon: status.value?.reachable ? "i-heroicons-signal" : "i-heroicons-signal-slash",
color: status.value?.reachable ? "success" : "error"
},
{
label: "Audio/Video",
value: status.value?.calls?.configured ? "Bereit" : "Nicht eingerichtet",
icon: status.value?.calls?.configured ? "i-heroicons-video-camera" : "i-heroicons-video-camera-slash",
color: status.value?.calls?.configured ? "success" : "warning"
}
])
const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
)
const loadMatrixInfo = async () => {
loading.value = true
try {
const [statusRes, identityRes, tenantSpaceRes, generalRoomRes] = await Promise.all([
$api("/api/communication/matrix/status"),
$api("/api/communication/matrix/me"),
$api("/api/communication/matrix/tenant-space"),
$api("/api/communication/matrix/rooms/general")
])
status.value = statusRes
identity.value = identityRes
tenantSpace.value = tenantSpaceRes
generalRoom.value = generalRoomRes
lastUpdated.value = new Date()
} catch (error) {
toast.add({
title: "Matrix-Status konnte nicht geladen werden",
color: "error"
})
} finally {
loading.value = false
}
}
const provisionMatrixAccount = async () => {
provisioning.value = true
try {
const res = await $api("/api/communication/matrix/me/provision", {
method: "POST"
})
provisionResult.value = res
identity.value = {
...identity.value,
matrixUserId: res.matrixUserId,
displayName: res.displayName
}
toast.add({
title: res.alreadyExisted ? "Matrix-Konto ist bereits vorhanden" : "Matrix-Konto erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Matrix-Konto konnte nicht erstellt werden",
color: "error"
})
} finally {
provisioning.value = false
}
}
const provisionTenantSpace = async () => {
tenantSpaceProvisioning.value = true
try {
const res = await $api("/api/communication/matrix/tenant-space/provision", {
method: "POST"
})
tenantSpaceProvisionResult.value = res
tenantSpace.value = {
tenantId: res.tenantId,
tenantName: res.tenantName,
alias: res.alias,
exists: true,
roomId: res.roomId,
servers: res.servers || []
}
toast.add({
title: res.alreadyExisted ? "Mandanten-Space ist bereits vorhanden" : "Mandanten-Space erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Mandanten-Space konnte nicht erstellt werden",
color: "error"
})
} finally {
tenantSpaceProvisioning.value = false
}
}
const provisionGeneralRoom = async () => {
generalRoomProvisioning.value = true
try {
const res = await $api("/api/communication/matrix/rooms/general/provision", {
method: "POST"
})
generalRoomProvisionResult.value = res
generalRoom.value = {
tenantId: res.tenantId,
tenantName: res.tenantName,
key: res.key,
name: res.name,
alias: res.alias,
exists: true,
roomId: res.roomId,
servers: res.servers || []
}
tenantSpace.value = {
...tenantSpace.value,
exists: true,
roomId: res.parentSpaceRoomId || tenantSpace.value?.roomId
}
toast.add({
title: res.alreadyExisted ? "Allgemeiner Chat ist bereits vorhanden" : "Allgemeiner Chat erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Allgemeiner Chat konnte nicht erstellt werden",
color: "error"
})
} finally {
generalRoomProvisioning.value = false
}
}
const formatDateTime = (value) => {
if (!value) return "-"
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(value)
}
onMounted(async () => {
await loadMatrixInfo()
})
</script>
<template>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-highlighted">
Kommunikation
</h1>
<p class="mt-1 text-sm text-muted">
Matrix-Verbindung und persönliche Kommunikationsidentität.
</p>
</div>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="outline"
:loading="loading"
@click="loadMatrixInfo"
>
Aktualisieren
</UButton>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<UCard
v-for="item in statusItems"
:key="item.label"
:ui="{ root: 'rounded-lg', body: 'p-4 sm:p-4' }"
>
<div class="flex items-start gap-3">
<UIcon
:name="item.icon"
class="mt-0.5 size-5 shrink-0"
:class="{
'text-success': item.color === 'success',
'text-error': item.color === 'error',
'text-warning': item.color === 'warning',
'text-muted': item.color === 'neutral'
}"
/>
<div class="min-w-0">
<p class="text-xs font-medium uppercase text-muted">
{{ item.label }}
</p>
<p class="mt-1 break-words text-sm font-medium text-highlighted">
{{ item.value }}
</p>
</div>
</div>
</UCard>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
<div class="space-y-4">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-user-circle" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Eigene Matrix-Identität
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Matrix-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ identity?.matrixUserId || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Anzeigename
</p>
<p class="mt-1 text-sm text-highlighted">
{{ identity?.displayName || "-" }}
</p>
</div>
</div>
<UAlert
v-if="provisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="provisionResult.alreadyExisted ? 'Matrix-Konto vorhanden' : 'Matrix-Konto erstellt'"
:description="provisionResult.matrixUserId"
/>
<UAlert
v-if="status && !status.reachable"
icon="i-heroicons-exclamation-triangle"
color="error"
variant="soft"
title="Matrix-Homeserver nicht erreichbar"
:description="status.error || 'Bitte prüfe den lokalen Matrix-Stack und die Backend-Konfiguration.'"
/>
<UAlert
v-else-if="status && !status.provisioningConfigured"
icon="i-heroicons-key"
color="warning"
variant="soft"
title="Matrix-Provisionierung nicht eingerichtet"
description="Bitte setze MATRIX_REGISTRATION_SHARED_SECRET in der Backend-Umgebung."
/>
</div>
<template #footer>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-muted">
Aktualisiert: {{ formatDateTime(lastUpdated) }}
</p>
<UButton
icon="i-heroicons-user-plus"
:loading="provisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionMatrixAccount"
>
Matrix-Konto erstellen
</UButton>
</div>
</template>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-building-office-2" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Mandanten-Space
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Alias
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ tenantSpace?.alias || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Status
</p>
<UBadge
class="mt-1"
:color="tenantSpace?.exists ? 'success' : 'neutral'"
variant="soft"
>
{{ tenantSpace?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
</UBadge>
</div>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Raum-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ tenantSpace?.roomId || "-" }}
</p>
</div>
<UAlert
v-if="tenantSpaceProvisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="tenantSpaceProvisionResult.alreadyExisted ? 'Mandanten-Space vorhanden' : 'Mandanten-Space erstellt'"
:description="tenantSpaceProvisionResult.alias"
/>
</div>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<UButton
icon="i-heroicons-plus"
:loading="tenantSpaceProvisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionTenantSpace"
>
Mandanten-Space erstellen
</UButton>
</div>
</template>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Allgemeiner Chat
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Alias
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ generalRoom?.alias || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Status
</p>
<UBadge
class="mt-1"
:color="generalRoom?.exists ? 'success' : 'neutral'"
variant="soft"
>
{{ generalRoom?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
</UBadge>
</div>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Raum-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ generalRoom?.roomId || "-" }}
</p>
</div>
<UAlert
v-if="generalRoomProvisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="generalRoomProvisionResult.alreadyExisted ? 'Allgemeiner Chat vorhanden' : 'Allgemeiner Chat erstellt'"
:description="generalRoomProvisionResult.alias"
/>
</div>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<UButton
icon="i-heroicons-plus"
:loading="generalRoomProvisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionGeneralRoom"
>
Allgemeinen Chat erstellen
</UButton>
<UButton
v-if="generalRoom?.exists"
to="/communication/chat"
icon="i-heroicons-chat-bubble-left-right"
color="neutral"
variant="outline"
>
Zum Chat
</UButton>
</div>
</template>
</UCard>
</div>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-video-camera" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Nächste Ausbaustufe
</h2>
</div>
</template>
<div class="space-y-3 text-sm text-muted">
<div class="flex gap-2">
<UIcon name="i-heroicons-building-office-2" class="mt-0.5 size-4 shrink-0" />
<span>Team- und Projekt-Räume im Mandanten-Space anlegen.</span>
</div>
<div class="flex gap-2">
<UIcon name="i-heroicons-users" class="mt-0.5 size-4 shrink-0" />
<span>FEDEO-Nutzer in Matrix-Räume synchronisieren.</span>
</div>
<div class="flex gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="mt-0.5 size-4 shrink-0" />
<span>Nativen FEDEO-Chat schrittweise an Matrix-Sync anbinden.</span>
</div>
</div>
</UCard>
</div>
</div>
</div>
</template>

View File

@@ -51,6 +51,7 @@ const itemInfo = ref({
dateOfPerformance: null,
paymentDays: auth.activeTenantData.standardPaymentDays,
payment_type: "transfer",
outgoingsepamandate: null,
availableInPortal: false,
customSurchargePercentage: 0,
created_by: auth.user.id,
@@ -90,6 +91,7 @@ const selectedServicecategorie = ref(null)
const customers = ref([])
const contacts = ref([])
const contracts = ref([])
const outgoingsepamandates = ref([])
const texttemplates = ref([])
const units = ref([])
const tenantUsers = ref([])
@@ -204,6 +206,7 @@ const setupData = async () => {
customers.value = await useEntities("customers").select("*", "customerNumber")
contacts.value = await useEntities("contacts").select("*")
contracts.value = await useEntities("contracts").select("*")
outgoingsepamandates.value = await useEntities("outgoingsepamandates").select("*")
texttemplates.value = await useEntities("texttemplates").select("*")
units.value = await useEntities("units").selectSpecial("*")
tenantUsers.value = (await useNuxtApp().$api(`/api/tenant/users`, {
@@ -211,11 +214,112 @@ const setupData = async () => {
})).users
}
const getMandateCustomerId = (mandate) => mandate?.customer?.id || mandate?.customer
const availableSepaMandates = computed(() => {
return outgoingsepamandates.value.filter((mandate) => {
return !mandate.archived
&& mandate.status === "Aktiv"
&& (!itemInfo.value.customer || getMandateCustomerId(mandate) === itemInfo.value.customer)
})
})
const getSelectedSepaMandate = () => {
return availableSepaMandates.value.find((mandate) => mandate.id === itemInfo.value.outgoingsepamandate) || null
}
const mapContractPaymentType = (paymentType) => {
if (paymentType === "Einzug") return "direct-debit"
if (paymentType === "Überweisung") return "transfer"
return null
}
const applyContractPaymentData = () => {
const selectedContract = contracts.value.find((contract) => contract.id === itemInfo.value.contract)
if (!selectedContract) return
const mappedPaymentType = mapContractPaymentType(selectedContract.paymentType)
if (mappedPaymentType) {
itemInfo.value.payment_type = mappedPaymentType
}
const contractMandateId = selectedContract.outgoingsepamandate?.id || selectedContract.outgoingsepamandate
if (mappedPaymentType === "direct-debit" && contractMandateId) {
itemInfo.value.outgoingsepamandate = contractMandateId
} else if (mappedPaymentType === "transfer") {
itemInfo.value.outgoingsepamandate = null
}
}
const applyDefaultSepaMandate = () => {
if (itemInfo.value.payment_type !== "direct-debit") {
itemInfo.value.outgoingsepamandate = null
return
}
const selectedContract = contracts.value.find((contract) => contract.id === itemInfo.value.contract)
const contractMandateId = selectedContract?.outgoingsepamandate?.id || selectedContract?.outgoingsepamandate
if (contractMandateId && availableSepaMandates.value.find((mandate) => mandate.id === contractMandateId)) {
itemInfo.value.outgoingsepamandate = contractMandateId
return
}
if (itemInfo.value.outgoingsepamandate && availableSepaMandates.value.find((mandate) => mandate.id === itemInfo.value.outgoingsepamandate)) {
return
}
const defaultMandate = availableSepaMandates.value.find((mandate) => mandate.defaultMandate)
|| availableSepaMandates.value[0]
itemInfo.value.outgoingsepamandate = defaultMandate?.id || null
}
watch(
() => itemInfo.value.contract,
() => applyContractPaymentData()
)
watch(
() => [itemInfo.value.customer, itemInfo.value.contract, itemInfo.value.payment_type, outgoingsepamandates.value.length],
() => applyDefaultSepaMandate()
)
const loaded = ref(false)
const normalizeEntityId = (value) => {
if (value === null || typeof value === "undefined") return null
return typeof value === "object" ? (value.id ?? null) : value
}
const normalizeCreatedDocumentRow = (row) => {
let normalizedRow = row
if (typeof normalizedRow === "string") {
try {
normalizedRow = JSON.parse(normalizedRow)
} catch {
normalizedRow = {
id: uuidv4(),
mode: "text",
text: normalizedRow,
}
}
}
if (!normalizedRow || typeof normalizedRow !== "object" || Array.isArray(normalizedRow)) {
normalizedRow = {
id: uuidv4(),
mode: "text",
text: String(row ?? ""),
}
}
return {
...normalizedRow,
id: normalizedRow.id || uuidv4(),
linkedEntitys: Array.isArray(normalizedRow.linkedEntitys) ? normalizedRow.linkedEntitys : [],
}
}
const normalizeCreatedDocumentRows = (rows) => Array.isArray(rows)
? rows.map((row) => normalizeCreatedDocumentRow(row))
: []
const setupPage = async () => {
await setupData()
@@ -227,6 +331,7 @@ const setupPage = async () => {
if (route.params.id) {
console.log(route.params)
itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,'',false)
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
itemInfo.value.taxType = normalizeTaxTypeValue(itemInfo.value.taxType)
await setContactPersonData()
checkCompatibilityWithInputPrice()
@@ -235,6 +340,8 @@ const setupPage = async () => {
if (!itemInfo.value.deliveryDateType) itemInfo.value.deliveryDateType = "Lieferdatum"
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
if (itemInfo.value.rows.find(i => i.agriculture)) {
processDieselPosition()
}
@@ -284,7 +391,7 @@ const setupPage = async () => {
mode: "title",
text: `${doc.title} vom ${dayjs(doc.documentDate).format("DD.MM.YYYY")}`
},
...doc.rows
...normalizeCreatedDocumentRows(doc.rows)
])
})
@@ -305,7 +412,7 @@ const setupPage = async () => {
console.log(linkedDocuments)
if (linkedDocuments.find(i => i.rows.find(x => x.agriculture.dieselUsage))) {
if (linkedDocuments.find(i => normalizeCreatedDocumentRows(i.rows).find(x => x.agriculture?.dieselUsage))) {
console.log("has diesel")
//Remove Existing Total Diesel Pos
@@ -347,7 +454,7 @@ const setupPage = async () => {
text: linkedDocument.title,
})
itemInfo.value.rows.push(...linkedDocument.rows)
itemInfo.value.rows.push(...normalizeCreatedDocumentRows(linkedDocument.rows))
}
for await (const doc of linkedDocuments.filter(i => quoteLikeDocumentTypes.includes(i.type))) {
@@ -359,7 +466,7 @@ const setupPage = async () => {
text: linkedDocument.title,
})
itemInfo.value.rows.push(...linkedDocument.rows)
itemInfo.value.rows.push(...normalizeCreatedDocumentRows(linkedDocument.rows))
}
itemInfo.value.rows.push({
@@ -428,7 +535,7 @@ const setupPage = async () => {
if (optionsToImport.title) itemInfo.value.title = linkedDocument.title
if (optionsToImport.description) itemInfo.value.description = linkedDocument.description
if (optionsToImport.startText) itemInfo.value.startText = linkedDocument.startText
if (optionsToImport.rows) itemInfo.value.rows = linkedDocument.rows
if (optionsToImport.rows) itemInfo.value.rows = normalizeCreatedDocumentRows(linkedDocument.rows)
if (optionsToImport.endText) itemInfo.value.endText = linkedDocument.endText
} else {
@@ -454,7 +561,7 @@ const setupPage = async () => {
itemInfo.value.title = linkedDocument.title
itemInfo.value.description = linkedDocument.description
itemInfo.value.startText = linkedDocument.startText
itemInfo.value.rows = linkedDocument.rows
itemInfo.value.rows = normalizeCreatedDocumentRows(linkedDocument.rows)
itemInfo.value.endText = linkedDocument.endText
}
@@ -1441,6 +1548,7 @@ const saveSerialInvoice = async () => {
project: itemInfo.value.project,
paymentDays: itemInfo.value.paymentDays,
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: itemInfo.value.outgoingsepamandate,
deliveryDateType: "Leistungszeitraum",
createdBy: itemInfo.value.createdBy,
created_by: itemInfo.value.created_by,
@@ -1528,6 +1636,7 @@ const saveDocument = async (state, resetup = false) => {
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
paymentDays: itemInfo.value.paymentDays,
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: itemInfo.value.outgoingsepamandate,
deliveryDateType: itemInfo.value.deliveryDateType,
info: {},
createdBy: itemInfo.value.createdBy,
@@ -1628,6 +1737,8 @@ const getTextTemplateByType = (type, pos) => {
}
const checkCompatibilityWithInputPrice = () => {
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
itemInfo.value.rows.forEach(row => {
if (!row.inputPrice) {
row.inputPrice = row.price
@@ -2189,6 +2300,24 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full"
/>
</UFormField>
<UFormField
class="min-w-0 flex-1"
label="Individueller Aufschlag:"
>
<UInput
type="number"
step="0.01"
v-model="itemInfo.customSurchargePercentage"
@change="updateCustomSurcharge"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">%</span>
</template>
</UInput>
</UFormField>
</InputGroup>
<InputGroup class="w-full items-start flex-wrap md:flex-nowrap">
<UFormField
class="min-w-0 flex-1"
label="Zahlungsart:"
@@ -2206,20 +2335,42 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
</USelectMenu>
</UFormField>
<UFormField
v-if="itemInfo.payment_type === 'direct-debit'"
class="min-w-0 flex-1"
label="Individueller Aufschlag:"
label="SEPA-Mandat:"
>
<UInput
type="number"
step="0.01"
v-model="itemInfo.customSurchargePercentage"
@change="updateCustomSurcharge"
<InputGroup>
<USelectMenu
v-model="itemInfo.outgoingsepamandate"
value-key="id"
label-key="reference"
:items="availableSepaMandates"
:search-input="{ placeholder: 'Suche...' }"
:filter-fields="['reference', 'status']"
class="w-full"
:disabled="!itemInfo.customer"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">%</span>
<template #default>
{{ getSelectedSepaMandate()?.reference || "Kein Mandat ausgewählt" }}
</template>
</UInput>
<template #item="{ item: mandate }">
{{ mandate.reference }} - {{ mandate.bankaccount?.displayLabel || "Bankverbindung" }}
</template>
</USelectMenu>
<UButton
variant="outline"
color="error"
v-if="itemInfo.outgoingsepamandate"
icon="i-heroicons-x-mark"
@click="itemInfo.outgoingsepamandate = null"
/>
<EntityModalButtons
type="outgoingsepamandates"
:id="itemInfo.outgoingsepamandate"
:create-query="{customer: itemInfo.customer}"
@return-data="(data) => itemInfo.outgoingsepamandate = data.id"
/>
</InputGroup>
</UFormField>
</InputGroup>
<UFormField
@@ -2336,7 +2487,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
>
<InputGroup>
<USelectMenu
:items="contracts.filter(i => i.customer?.id === itemInfo.customer && (itemInfo.plant ? itemInfo.plant === i.plant : true))"
:items="contracts.filter(i => (i.customer?.id || i.customer) === itemInfo.customer && (itemInfo.plant ? itemInfo.plant === i.plant : true))"
v-model="itemInfo.contract"
value-key="id"
label-key="name"
@@ -2361,7 +2512,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
@click="itemInfo.contract = null"
/>
<EntityModalButtons
type="projects"
type="contracts"
:id="itemInfo.contract"
:create-query="{customer: itemInfo.customer}"
@return-data="(data) => itemInfo.contract = data.id"

View File

@@ -1,25 +1,57 @@
<script setup>
const createddocuments = ref([])
const bankaccounts = ref([])
const selected = ref([])
const selectedBankaccount = ref(null)
const loading = ref(true)
const toast = useToast()
const router = useRouter()
const setup = async () => {
createddocuments.value = (await useEntities("createddocuments").select()).filter(i => i.payment_type === "direct-debit")
loading.value = true
const [documents, accounts] = await Promise.all([
useEntities("createddocuments").select("*, customer(id,name), outgoingsepamandate(*, bankaccount(*)), statementallocations(*)"),
useEntities("bankaccounts").select("*")
])
createddocuments.value = documents.filter(i => i.payment_type === "direct-debit" && i.outgoingsepamandate)
bankaccounts.value = accounts.filter(i => !i.archived && !i.expired)
selected.value = createddocuments.value
selectedBankaccount.value = bankaccounts.value[0]?.id || null
loading.value = false
}
setup()
const createExport = async () => {
if (!selectedBankaccount.value) {
toast.add({ title: "Bitte wählen Sie ein Gläubigerkonto aus.", color: "error" })
return
}
if (!selected.value.length) {
toast.add({ title: "Bitte wählen Sie mindestens einen Beleg aus.", color: "error" })
return
}
//NUMMERN MAPPEN ZU IDS UND AN BACKEND FUNKTION ÜBERGEBEN
const ids = selected.value.map((i) => i.id)
const res = await useNuxtApp().$api("/api/exports/sepa", {
method: "POST",
body: {
idsToExport: ids
idsToExport: ids,
creditorBankaccountId: selectedBankaccount.value
}
})
if (res.success) {
toast.add({ title: "SEPA-Export wird erstellt. Sie finden die Datei anschließend in der Exportliste." })
await router.push("/export")
} else {
toast.add({ title: res.message || "Der SEPA-Export konnte nicht gestartet werden.", color: "error" })
}
}
@@ -34,13 +66,46 @@ const createExport = async () => {
</UButton>
</template>
</UDashboardNavbar>
<div class="p-4">
<UFormField label="Gläubigerkonto" class="max-w-xl">
<USelectMenu
v-model="selectedBankaccount"
value-key="id"
label-key="name"
:items="bankaccounts"
:search-input="{ placeholder: 'Konto suchen...' }"
:filter-fields="['name', 'iban', 'bankId']"
class="w-full"
>
<template #default>
{{ bankaccounts.find(i => i.id === selectedBankaccount)?.name || "Gläubigerkonto auswählen" }}
</template>
<template #item="{ item }">
{{ item.name || item.ownerName || "Bankkonto" }} - {{ item.iban }} - {{ item.bankId }}
</template>
</USelectMenu>
</UFormField>
</div>
<UTable
:loading="true"
:loading="loading"
v-model="selected"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
:data="createddocuments"
:columns="normalizeTableColumns([
{ key: 'documentNumber', label: 'Belegnummer' },
{ key: 'customer', label: 'Kunde' },
{ key: 'documentDate', label: 'Belegdatum' },
{ key: 'outgoingsepamandate', label: 'SEPA-Mandat' },
])"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine SEPA-Belege anzuzeigen' }"
/>
>
<template #customer-cell="{ row }">
{{ row.original.customer?.name || "-" }}
</template>
<template #outgoingsepamandate-cell="{ row }">
{{ row.original.outgoingsepamandate?.reference || "-" }}
</template>
</UTable>
</template>
<style scoped>

View File

@@ -35,6 +35,7 @@ const defaultFeatures = {
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
outgoingsepamandates: true,
costcentres: true,
branches: true,
teams: true,
@@ -82,6 +83,7 @@ const featureOptions = [
{ key: "createDocument", label: "Buchhaltung: Ausgangsbelege" },
{ key: "serialInvoice", label: "Buchhaltung: Serienvorlagen" },
{ key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" },
{ key: "outgoingsepamandates", label: "Buchhaltung: Ausgehende SEPA-Mandate" },
{ key: "costcentres", label: "Buchhaltung: Kostenstellen" },
{ key: "branches", label: "Stammdaten: Niederlassungen" },
{ key: "teams", label: "Mitarbeiter: Teams" },

View File

@@ -0,0 +1,50 @@
self.addEventListener("push", (event) => {
let data = {}
try {
data = event.data ? event.data.json() : {}
} catch (error) {
data = {
title: "FEDEO",
message: event.data?.text() || "Neue Benachrichtigung",
payload: {},
}
}
const payload = data.payload || {}
const title = data.title || "FEDEO"
const options = {
body: data.message || "",
icon: payload.icon || "/favicon.ico",
badge: payload.badge || "/favicon.ico",
data: {
notificationId: data.id,
link: payload.link || "/",
},
}
event.waitUntil(self.registration.showNotification(title, options))
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const targetUrl = new URL(event.notification.data?.link || "/", self.location.origin).href
event.waitUntil((async () => {
const windows = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
})
for (const client of windows) {
if ("focus" in client) {
await client.focus()
if ("navigate" in client) await client.navigate(targetUrl)
return
}
}
await self.clients.openWindow(targetUrl)
})())
})

View File

@@ -50,10 +50,24 @@ import {useFunctions} from "~/composables/useFunctions.js";
import signDate from "~/components/columnRenderings/signDate.vue";
import sepaDate from "~/components/columnRenderings/sepaDate.vue";
import bankAccounts from "~/components/columnRenderings/bankAccounts.vue";
import bankAccount from "~/components/columnRenderings/bankAccount.vue";
import outgoingSepaMandate from "~/components/columnRenderings/outgoingSepaMandate.vue";
// @ts-ignore
export const useDataStore = defineStore('data', () => {
const outgoingSepaMandateTypeOptions = [
{ key: "CORE", label: "Basislastschrift (CORE)" },
{ key: "B2B", label: "Firmenlastschrift (B2B)" }
]
const outgoingSepaSequenceTypeOptions = [
{ key: "RCUR", label: "Wiederkehrend (RCUR)" },
{ key: "OOFF", label: "Einmalig (OOFF)" },
{ key: "FRST", label: "Erstlastschrift (FRST)" },
{ key: "FNAL", label: "Letztlastschrift (FNAL)" }
]
const dataTypes = {
@@ -173,7 +187,7 @@ export const useDataStore = defineStore('data', () => {
numberRangeHolder: "customerNumber",
historyItemHolder: "customer",
sortColumn: "customerNumber",
selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*), customerinventoryitems(*), customerspaces(*)",
selectWithInformation: "*, projects(*), plants(*), contracts(*), outgoingsepamandates(*, bankaccount(*)), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*), customerinventoryitems(*), customerspaces(*)",
filters: [{
name: "Archivierte ausblenden",
default: true,
@@ -457,7 +471,7 @@ export const useDataStore = defineStore('data', () => {
inputColumn: "Allgemeines"
},*/
],
showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Kundenlagerplätze', key: 'customerspaces'},{label: 'Wiki'}]
showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Ausgehende SEPA-Mandate', key: 'outgoingsepamandates', type: 'outgoingsepamandates'},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Kundenlagerplätze', key: 'customerspaces'},{label: 'Wiki'}]
},
members: {
isArchivable: true,
@@ -822,7 +836,7 @@ export const useDataStore = defineStore('data', () => {
"Allgemeines",
"Abrechnung"
],
selectWithInformation: "*, customer(*), contracttype(*), files(*)",
selectWithInformation: "*, customer(*), contracttype(*), outgoingsepamandate(*, bankaccount(*)), files(*)",
templateColumns: [
{
key: 'contractNumber',
@@ -936,6 +950,26 @@ export const useDataStore = defineStore('data', () => {
{label:'Überweisung'}
],
inputColumn: "Abrechnung"
},{
key: 'outgoingsepamandate',
label: "SEPA-Mandat",
component: outgoingSepaMandate,
inputType: "select",
selectDataType: "outgoingsepamandates",
selectOptionAttribute: "reference",
selectSearchAttributes: ["reference", "status"],
selectDataTypeFilter: function (i, item) {
const mandateCustomer = i.customer?.id || i.customer
return !i.archived && i.status === "Aktiv" && (!item.customer || mandateCustomer === item.customer)
},
entityModalButtonsType: "outgoingsepamandates",
entityModalCreateQueryFunction: function (item) {
return {customer: item.customer}
},
showFunction: function (item) {
return item.paymentType === "Einzug"
},
inputColumn: "Abrechnung"
},{
key: "billingInterval",
label: "Abrechnungsintervall",
@@ -1129,6 +1163,140 @@ export const useDataStore = defineStore('data', () => {
],
showTabs: [{ label: "Informationen" }]
},
outgoingsepamandates: {
isArchivable: true,
label: "Ausgehende SEPA-Mandate",
labelSingle: "Ausgehendes SEPA-Mandat",
isStandardEntity: true,
redirect: true,
numberRangeHolder: "reference",
historyItemHolder: "outgoingsepamandate",
sortColumn: "reference",
selectWithInformation: "*, customer(*), bankaccount(*)",
filters: [{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
return !row.archived
}
}],
inputColumns: [
"Allgemeines",
"Mandat"
],
templateColumns: [
{
key: "reference",
label: "Mandatsreferenz",
title: true,
required: true,
inputIsNumberRange: true,
inputType: "text",
inputColumn: "Allgemeines",
sortable: true
},
{
key: "customer",
label: "Kunde",
component: customer,
required: true,
inputType: "select",
selectDataType: "customers",
selectOptionAttribute: "name",
selectSearchAttributes: ["name", "customerNumber"],
inputColumn: "Allgemeines",
sortable: true
},
{
key: "bankaccount",
label: "Bankverbindung",
component: bankAccount,
required: true,
inputType: "select",
selectDataType: "entitybankaccounts",
selectOptionAttribute: "displayLabel",
selectSearchAttributes: ["displayLabel", "iban", "bankName"],
entityModalButtonsType: "entitybankaccounts",
inputColumn: "Mandat"
},
{
key: "status",
label: "Status",
required: true,
defaultValue: "Entwurf",
inputType: "select",
selectValueAttribute: "label",
selectManualOptions: [
{ label: "Entwurf" },
{ label: "Aktiv" },
{ label: "Widerrufen" },
{ label: "Abgelaufen" }
],
inputColumn: "Allgemeines",
sortable: true
},
{
key: "mandateType",
label: "Mandatstyp",
required: true,
defaultValue: "CORE",
inputType: "select",
selectValueAttribute: "key",
selectOptionAttribute: "label",
selectManualOptions: outgoingSepaMandateTypeOptions,
inputColumn: "Mandat",
sortable: true
},
{
key: "sequenceType",
label: "Sequenz",
required: true,
defaultValue: "RCUR",
inputType: "select",
selectValueAttribute: "key",
selectOptionAttribute: "label",
selectManualOptions: outgoingSepaSequenceTypeOptions,
inputColumn: "Mandat",
sortable: true
},
{
key: "signedAt",
label: "Unterschrieben am",
inputType: "date",
inputColumn: "Mandat",
sortable: true
},
{
key: "validFrom",
label: "Gültig ab",
inputType: "date",
inputColumn: "Mandat",
sortable: true
},
{
key: "validUntil",
label: "Gültig bis",
inputType: "date",
inputColumn: "Mandat",
sortable: true
},
{
key: "defaultMandate",
label: "Standardmandat",
inputType: "bool",
defaultValue: false,
inputColumn: "Allgemeines",
sortable: true
},
{
key: "notes",
label: "Notizen",
inputType: "textarea",
inputColumn: "Allgemeines"
}
],
showTabs: [{ label: "Informationen" }, { label: "Wiki" }]
},
absencerequests: {
isArchivable: true,
label: "Abwesenheiten",
@@ -2130,7 +2298,7 @@ export const useDataStore = defineStore('data', () => {
isArchivable: true,
label: "Dokumente",
labelSingle: "Dokument",
selectWithInformation: "*, files(*), statementallocations(*)",
selectWithInformation: "*, files(*), statementallocations(*), outgoingsepamandate(*)",
filters: [
{
name: "Archivierte ausblenden",
@@ -3580,5 +3748,7 @@ export const useDataStore = defineStore('data', () => {
return {
dataTypes,
documentTypesForCreation,
outgoingSepaMandateTypeOptions,
outgoingSepaSequenceTypeOptions,
}
})

197
matrix/README.md Normal file
View File

@@ -0,0 +1,197 @@
# Matrix-Stack in der FEDEO Compose
Der Matrix-Stack liegt in derselben `docker-compose.yml` wie FEDEO und ist über das Compose-Profil `matrix` aktivierbar.
## Enthaltene Dienste
- `matrix-db`: PostgreSQL für Synapse
- `matrix-redis`: Redis für Synapse und LiveKit
- `matrix-synapse`: Matrix Homeserver
- `matrix-well-known`: Auslieferung von `.well-known/matrix/client` und `.well-known/matrix/server`
- `matrix-turn`: coturn für stabile WebRTC-Verbindungen
- `matrix-livekit`: LiveKit SFU für MatrixRTC-Konferenzen
- `matrix-rtc-jwt`: MatrixRTC Authorization Service für LiveKit-JWTs
## Vorbereitung
Lege im Repo eine `.env` auf Basis von `.env.example` an und passe mindestens diese Werte an:
- `MATRIX_SERVER_NAME`
- `MATRIX_HOMESERVER_HOST`
- `MATRIX_RTC_HOST`
- `MATRIX_TURN_HOST`
- `MATRIX_POSTGRES_PASSWORD`
- `MATRIX_TURN_SHARED_SECRET`
- `LIVEKIT_KEY`
- `LIVEKIT_SECRET`
Passe außerdem die Dateien in `matrix/well-known/` an, falls die Domains nicht `fedeo.de`, `matrix.fedeo.de` und `call.fedeo.de` heißen.
## Synapse-Konfiguration erzeugen
Synapse benötigt vor dem ersten Start eine generierte `homeserver.yaml`. Der Befehl bleibt innerhalb derselben Compose:
```bash
docker compose --profile matrix run --rm \
-e SYNAPSE_SERVER_NAME="${MATRIX_SERVER_NAME}" \
-e SYNAPSE_REPORT_STATS=no \
matrix-synapse generate
```
Danach `matrix/synapse/homeserver.yaml` prüfen und mindestens diese Punkte setzen:
```yaml
public_baseurl: "https://matrix.fedeo.de/"
database:
name: psycopg2
args:
user: synapse
password: "<MATRIX_POSTGRES_PASSWORD>"
database: synapse
host: matrix-db
cp_min: 5
cp_max: 10
redis:
enabled: true
host: matrix-redis
turn_uris:
- "turn:<MATRIX_TURN_HOST>:3478?transport=udp"
- "turn:<MATRIX_TURN_HOST>:3478?transport=tcp"
turn_shared_secret: "<MATRIX_TURN_SHARED_SECRET>"
turn_user_lifetime: "1h"
experimental_features:
msc3266_enabled: true
msc4222_enabled: true
max_event_delay_duration: 24h
rc_message:
per_second: 0.5
burst_count: 30
rc_delayed_event_mgmt:
per_second: 1
burst_count: 20
```
## Start
```bash
docker compose --profile matrix up -d
```
Ohne Profil startet weiterhin nur der bisherige FEDEO-Stack:
```bash
docker compose up -d
```
## Hinweise
- Die Matrix-Services sind bewusst im bestehenden Compose-Stack definiert, damit FEDEO nicht in mehrere Deployment-Dateien zerfällt.
- Die aktuellen Ports für TURN und LiveKit müssen auf der Firewall des Servers freigegeben werden.
- Federation sollte erst nach einer expliziten Entscheidung geöffnet werden. Für B2B-Kommunikation ist eine Allowlist sinnvoll.
- Die Werte in `.env.example` sind Platzhalter und nicht produktionssicher.
## Lokaler Entwicklungsstack
Für lokale Entwicklung gibt es zusätzlich das Profil `matrix-dev`. Es nutzt direkte Localhost-Ports und braucht keine öffentlichen Domains, kein ACME und keine Traefik-Router.
Lokale Dienste:
- Synapse: `http://localhost:8008`
- Element Web: `http://localhost:8080`
- MatrixRTC JWT-Service: `http://localhost:8081`
- LiveKit: `ws://localhost:7880`
- TURN: `localhost:3478`
### Lokale Synapse-Konfiguration erzeugen
```bash
docker compose --profile matrix-dev run --rm \
-e SYNAPSE_SERVER_NAME=localhost \
-e SYNAPSE_REPORT_STATS=no \
matrix-dev-synapse generate
```
Danach `matrix/dev/synapse/homeserver.yaml` für die lokale Compose anpassen:
```yaml
public_baseurl: "http://localhost:8008/"
database:
name: psycopg2
args:
user: synapse
password: "synapse-dev-password"
database: synapse
host: matrix-dev-db
cp_min: 5
cp_max: 10
redis:
enabled: true
host: matrix-dev-redis
enable_registration: true
enable_registration_without_verification: true
turn_uris:
- "turn:localhost:3478?transport=udp"
- "turn:localhost:3478?transport=tcp"
turn_shared_secret: "matrix-dev-turn-secret"
turn_user_lifetime: "1h"
experimental_features:
msc3266_enabled: true
msc4222_enabled: true
```
### Lokalen Stack starten
```bash
docker compose --profile matrix-dev up -d \
matrix-dev-db \
matrix-dev-redis \
matrix-dev-synapse \
matrix-dev-turn \
matrix-dev-livekit \
matrix-dev-rtc-jwt \
matrix-dev-element
```
Einen lokalen Admin-Nutzer kannst du danach im Synapse-Container anlegen:
```bash
docker compose --profile matrix-dev exec matrix-dev-synapse \
register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008
```
Anschließend Element Web unter `http://localhost:8080` öffnen und mit dem lokalen Matrix-Nutzer anmelden.
Wenn FEDEO selbst parallel lokal laufen soll, starte die FEDEO-Dienste separat wie gewohnt. Der lokale Matrix-Stack ist absichtlich über direkte Ports erreichbar, damit er unabhängig von DNS, TLS und Traefik getestet werden kann.
## Erste FEDEO-Backend-Integration
Das Backend stellt geschützte Matrix-Endpunkte unter `/api/communication/matrix/*` bereit:
- `GET /api/communication/matrix/status`: prüft Konfiguration und Erreichbarkeit des Matrix-Homeservers
- `GET /api/communication/matrix/me`: zeigt die aus dem FEDEO-Nutzer abgeleitete Matrix-ID
- `POST /api/communication/matrix/me/provision`: legt den Matrix-Account für den angemeldeten FEDEO-Nutzer per Synapse-Shared-Secret-Registrierung an
Für lokale Provisionierung muss `MATRIX_REGISTRATION_SHARED_SECRET` aus `matrix/dev/synapse/homeserver.yaml` in der Backend-Umgebung gesetzt werden. Die lokale Synapse-Konfiguration ist absichtlich nicht versioniert, weil sie Secrets enthält.
In der lokalen Entwicklung liest das Backend dieses Secret als Fallback direkt aus `matrix/dev/synapse/homeserver.yaml`, sofern `NODE_ENV` nicht `production` ist. Auf Servern muss das Secret weiterhin explizit über die Umgebung oder das Secret-Management gesetzt werden.
Für den eingebetteten Element-Login in FEDEO muss in der lokalen Synapse-Konfiguration außerdem der kurzlebige Login-Token-Flow aktiv sein:
```yaml
login_via_existing_session:
enabled: true
require_ui_auth: false
token_timeout: "5m"
```
Nach einer Änderung an `matrix/dev/synapse/homeserver.yaml` muss `matrix-dev-synapse` neu gestartet werden.

View File

@@ -0,0 +1,21 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "http://localhost:8008",
"server_name": "localhost"
}
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "http://localhost:8081"
}
],
"disable_custom_urls": false,
"disable_guests": true,
"brand": "FEDEO Matrix Dev",
"default_theme": "light",
"features": {
"feature_video_rooms": true
}
}

11
matrix/well-known/client Normal file
View File

@@ -0,0 +1,11 @@
{
"m.homeserver": {
"base_url": "https://matrix.fedeo.de"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://call.fedeo.de/livekit/jwt"
}
]
}

3
matrix/well-known/server Normal file
View File

@@ -0,0 +1,3 @@
{
"m.server": "matrix.fedeo.de:443"
}