Compare commits
43 Commits
9ba5f26efc
...
80b2b1d097
| Author | SHA1 | Date | |
|---|---|---|---|
| 80b2b1d097 | |||
| f5755993b5 | |||
| 0f56102030 | |||
| 60d846baa9 | |||
| a28b910d4d | |||
| 4aeefb2b83 | |||
| 24c09d7891 | |||
| 77eabe7e18 | |||
| e4073e01ad | |||
| 248da3412c | |||
| c93ea4284d | |||
| 7c68ce61f2 | |||
| f6dd37b458 | |||
| bb54a8779e | |||
| 6e14f48770 | |||
| 4d24e3a657 | |||
| f33ccf730a | |||
| 8824b1c9c8 | |||
| 571c24f250 | |||
| b03af21e97 | |||
| b1e102ca5d | |||
| 8b40be7909 | |||
| 655459a46b | |||
| 5fca7792a2 | |||
| 30b6ffcc20 | |||
| 7f66f66cfa | |||
| d0de3cb92e | |||
| c893574cb1 | |||
| eb2dd03ef9 | |||
| b322d0c173 | |||
| 54ae136f0d | |||
| 00e1e88dd9 | |||
| 3984e218db | |||
| d9c3c8d07c | |||
| c6a0d59c29 | |||
| 9592e2b062 | |||
| d522cbb49d | |||
| 8d7bc2e97c | |||
| 44017a768b | |||
| 683d073b6e | |||
| cb939f2197 | |||
| 0e71899c57 | |||
| 6b82f2b629 |
103
.env.example
Normal file
103
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
|
||||
# Lokale Runtime-Daten und generierte Konfigurationen
|
||||
matrix/postgres/
|
||||
matrix/synapse/
|
||||
matrix/dev/postgres/
|
||||
matrix/dev/synapse/
|
||||
51
README.md
51
README.md
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ALTER TABLE "auth_profiles"
|
||||
ADD COLUMN "availability_note" text;
|
||||
ADD COLUMN IF NOT EXISTS "availability_note" text;
|
||||
|
||||
53
backend/db/migrations/0037_outgoing_sepa_mandates.sql
Normal file
53
backend/db/migrations/0037_outgoing_sepa_mandates.sql
Normal 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)
|
||||
);
|
||||
43
backend/db/migrations/0038_communication_rooms.sql
Normal file
43
backend/db/migrations/0038_communication_rooms.sql
Normal 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");
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
57
backend/db/schema/communication_rooms.ts
Normal file
57
backend/db/schema/communication_rooms.ts
Normal 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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
50
backend/db/schema/notification_push_subscriptions.ts
Normal file
50
backend/db/schema/notification_push_subscriptions.ts
Normal 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
|
||||
61
backend/db/schema/outgoingsepamandates.ts
Normal file
61
backend/db/schema/outgoingsepamandates.ts
Normal 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
|
||||
@@ -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"),
|
||||
|
||||
|
||||
7
backend/docker-entrypoint.sh
Normal file
7
backend/docker-entrypoint.sh
Normal 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
10423
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
490
backend/src/modules/bootstrap.service.ts
Normal file
490
backend/src/modules/bootstrap.service.ts
Normal 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")
|
||||
}
|
||||
1425
backend/src/modules/matrix.service.ts
Normal file
1425
backend/src/modules/matrix.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
private nl2br(s: string) {
|
||||
return s.replace(/\n/g, '<br/>');
|
||||
return s.replace(/\n/g, "<br/>")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
323
backend/src/routes/communication.ts
Normal file
323
backend/src/routes/communication.ts
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
170
docker-compose.selfhost.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
371
docs/kommunikationslösung-matrix.md
Normal file
371
docs/kommunikationslösung-matrix.md
Normal 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/
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@ const props = defineProps({
|
||||
required: true
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
type: [String, Number],
|
||||
},
|
||||
createQuery: {
|
||||
type: Object,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
16
frontend/components/columnRenderings/bankAccount.vue
Normal file
16
frontend/components/columnRenderings/bankAccount.vue
Normal 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>
|
||||
26
frontend/components/columnRenderings/outgoingSepaMandate.vue
Normal file
26
frontend/components/columnRenderings/outgoingSepaMandate.vue
Normal 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>
|
||||
89
frontend/composables/useDesktopPush.js
Normal file
89
frontend/composables/useDesktopPush.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,8 @@ export default defineNuxtConfig({
|
||||
|
||||
public: {
|
||||
apiBase: '',
|
||||
pdfLicense: ''
|
||||
pdfLicense: '',
|
||||
matrixElementUrl: process.env.NUXT_PUBLIC_MATRIX_ELEMENT_URL || 'http://localhost:8080'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
119
frontend/package-lock.json
generated
119
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1445
frontend/pages/communication/chat.vue
Normal file
1445
frontend/pages/communication/chat.vue
Normal file
File diff suppressed because it is too large
Load Diff
501
frontend/pages/communication/index.vue
Normal file
501
frontend/pages/communication/index.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
50
frontend/public/fedeo-push-sw.js
Normal file
50
frontend/public/fedeo-push-sw.js
Normal 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)
|
||||
})())
|
||||
})
|
||||
@@ -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
197
matrix/README.md
Normal 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.
|
||||
21
matrix/dev/element-config.json
Normal file
21
matrix/dev/element-config.json
Normal 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
11
matrix/well-known/client
Normal 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
3
matrix/well-known/server
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"m.server": "matrix.fedeo.de:443"
|
||||
}
|
||||
Reference in New Issue
Block a user