Compare commits
43 Commits
c54e9a51a5
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d3ad53bcf0 | |||
| 4ab4c24b1a | |||
| e7107f151a | |||
| c222b793b0 | |||
| 5dcdc29a18 | |||
| fbea66b754 | |||
| 621243ba7b | |||
| dd2a9926dc | |||
| 3230fc6cf0 | |||
| b8220685d7 | |||
| b7b8a2077a | |||
| 5e7a1f9aee | |||
| 6c613857fa | |||
| 430ab74522 | |||
| 5585bd7012 | |||
| 657a4f9e85 | |||
| 769185cd82 | |||
| 37d08ee984 | |||
| 720c70845e | |||
| ae379b0f22 | |||
| 6a14b0c1ca | |||
| d8273e794d | |||
| 1711f975bb | |||
| 4e3f1e0c15 | |||
| a9b063aa89 | |||
| 473c92d2e4 | |||
| 3153a7a669 | |||
| d0f5a72350 | |||
| 4c03a96cbe | |||
| bf9e397e51 | |||
| 31ef408b66 | |||
| a945aaffe3 | |||
| 5f66f81ade | |||
| ee7d3d3afc | |||
| b3067e0e00 | |||
| e30722995e | |||
| bbef553e6b | |||
| 53632d25ac | |||
| 2eaa49bf05 | |||
| 14026181c2 | |||
| d67da7e02a | |||
| 3b3a879b02 | |||
| 365cec206d |
30
README.md
@@ -146,7 +146,7 @@ Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration di
|
||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
|
||||
```
|
||||
|
||||
Der schnelle One-Liner mit direktem Stack-Start:
|
||||
Der One-Liner von der Webseite nutzt den schnellen Standardpfad und startet den Stack direkt:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
|
||||
@@ -491,17 +491,41 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
|
||||
|
||||
## Updates
|
||||
|
||||
Bei neuen Versionen:
|
||||
Script-installierte Instanzen liegen standardmassig unter `/opt/fedeo`. Ein Update besteht daraus, die Selfhost-Dateien zu erneuern, die vorgebauten Images zu ziehen und den Compose-Stack neu zu starten.
|
||||
|
||||
Vor kritischen Updates sollte ein Backup der persistenten Daten erstellt werden:
|
||||
|
||||
```bash
|
||||
cd /opt/fedeo
|
||||
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" .env postgres minio
|
||||
```
|
||||
|
||||
Danach die aktuellen Selfhost-Dateien laden und den Stack aktualisieren:
|
||||
|
||||
```bash
|
||||
cd /opt/fedeo
|
||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
|
||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
|
||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
|
||||
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden.
|
||||
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an, solange `FEDEO_RUN_MIGRATIONS` nicht deaktiviert wurde. Falls Migrationen manuell erzwungen werden sollen:
|
||||
|
||||
```bash
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Anschliessend den Zustand prüfen:
|
||||
|
||||
```bash
|
||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
|
||||
curl -I https://app.example.com
|
||||
curl https://app.example.com/backend/health
|
||||
```
|
||||
|
||||
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
ALTER TABLE "tenant_export_jobs"
|
||||
ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant";
|
||||
|
||||
ALTER TABLE "tenants"
|
||||
ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = 'public'
|
||||
AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tenants"
|
||||
ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk"
|
||||
FOREIGN KEY ("locked_by_export_job_id")
|
||||
REFERENCES "tenant_export_jobs"("id")
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
21
backend/db/migrations/0056_auth_refresh_tokens.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS "auth_refresh_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"tenant_id" bigint,
|
||||
"token_hash" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
CONSTRAINT "auth_refresh_tokens_token_hash_unique" UNIQUE("token_hash"),
|
||||
CONSTRAINT "auth_refresh_tokens_user_id_auth_users_id_fk"
|
||||
FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id")
|
||||
ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "auth_refresh_tokens_tenant_id_tenants_id_fk"
|
||||
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
|
||||
ON DELETE set null ON UPDATE cascade
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_user_id_idx"
|
||||
ON "auth_refresh_tokens" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_expires_at_idx"
|
||||
ON "auth_refresh_tokens" ("expires_at");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "incominginvoices"
|
||||
ADD COLUMN IF NOT EXISTS "preparation_source" text,
|
||||
ADD COLUMN IF NOT EXISTS "e_invoice_syntax" text,
|
||||
ADD COLUMN IF NOT EXISTS "e_invoice_profile" text,
|
||||
ADD COLUMN IF NOT EXISTS "e_invoice_validation" jsonb;
|
||||
@@ -365,6 +365,27 @@
|
||||
"when": 1783247083934,
|
||||
"tag": "0054_tenant_export_jobs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 52,
|
||||
"version": "7",
|
||||
"when": 1784021669805,
|
||||
"tag": "0055_tenant_export_maintenance_lock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "7",
|
||||
"when": 1784548800000,
|
||||
"tag": "0056_auth_refresh_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "7",
|
||||
"when": 1784635200000,
|
||||
"tag": "0057_incoming_invoice_einvoice_metadata",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
20
backend/db/schema/auth_refresh_tokens.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { pgTable, text, timestamp, uuid, bigint } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { tenants } from "./tenants"
|
||||
|
||||
export const authRefreshTokens = pgTable("auth_refresh_tokens", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.references(() => tenants.id, { onDelete: "set null", onUpdate: "cascade" }),
|
||||
tokenHash: text("token_hash").notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
})
|
||||
|
||||
export type AuthRefreshToken = typeof authRefreshTokens.$inferSelect
|
||||
export type NewAuthRefreshToken = typeof authRefreshTokens.$inferInsert
|
||||
@@ -40,6 +40,11 @@ export const incominginvoices = pgTable("incominginvoices", {
|
||||
|
||||
paymentType: text("paymentType"),
|
||||
|
||||
preparationSource: text("preparation_source"),
|
||||
eInvoiceSyntax: text("e_invoice_syntax"),
|
||||
eInvoiceProfile: text("e_invoice_profile"),
|
||||
eInvoiceValidation: jsonb("e_invoice_validation"),
|
||||
|
||||
accounts: jsonb("accounts").notNull().default([
|
||||
{
|
||||
account: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./auth_roles"
|
||||
export * from "./auth_tenant_users"
|
||||
export * from "./auth_user_roles"
|
||||
export * from "./auth_users"
|
||||
export * from "./auth_refresh_tokens"
|
||||
export * from "./bankaccounts"
|
||||
export * from "./bankrequisitions"
|
||||
export * from "./bankstatements"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { lockedTenantEnum } from "./enums"
|
||||
|
||||
export const tenantExportJobs = pgTable("tenant_export_jobs", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -26,6 +27,8 @@ export const tenantExportJobs = pgTable("tenant_export_jobs", {
|
||||
|
||||
createdBy: uuid("created_by").references(() => authUsers.id),
|
||||
|
||||
operation: text("operation").notNull().default("export"),
|
||||
previousTenantLocked: lockedTenantEnum("previous_tenant_locked"),
|
||||
status: text("status").notNull().default("queued"),
|
||||
filename: text("filename").notNull(),
|
||||
storagePath: text("storage_path"),
|
||||
|
||||
@@ -60,6 +60,15 @@ export const tenants = pgTable(
|
||||
city: "",
|
||||
name: "",
|
||||
street: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
website: "",
|
||||
taxNumber: "",
|
||||
vatId: "",
|
||||
bankName: "",
|
||||
bankAccountOwner: "",
|
||||
iban: "",
|
||||
bic: "",
|
||||
}),
|
||||
|
||||
features: jsonb("features").default({
|
||||
@@ -189,6 +198,7 @@ export const tenants = pgTable(
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
|
||||
locked: lockedTenantEnum("locked"),
|
||||
lockedByExportJobId: uuid("locked_by_export_job_id"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
127
backend/package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"crypto": "^1.0.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"fastify": "^5.5.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"handlebars": "^4.7.8",
|
||||
@@ -5290,6 +5291,24 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
|
||||
@@ -5850,6 +5869,18 @@
|
||||
"eventemitter3": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
@@ -6881,6 +6912,18 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/anynum": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||
@@ -8004,10 +8047,10 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
|
||||
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8016,7 +8059,28 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.10.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
|
||||
"integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^3.0.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"is-unsafe": "^2.0.0",
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"strnum": "^2.4.1",
|
||||
"xml-naming": "^0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -8517,6 +8581,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unsafe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
|
||||
"integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@@ -9098,6 +9174,21 @@
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -9940,16 +10031,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
|
||||
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anynum": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "3.1.7",
|
||||
@@ -10351,6 +10445,21 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||
"build": "tsc",
|
||||
"test:einvoice": "tsx --test tests/einvoice.test.ts",
|
||||
"start": "node dist/src/index.js",
|
||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||
"invoices:delete-range": "tsx scripts/delete-createddocument-range.ts",
|
||||
"members:import:csv": "tsx scripts/import-members-csv.ts",
|
||||
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
|
||||
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
|
||||
@@ -42,6 +44,7 @@
|
||||
"crypto": "^1.0.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"fastify": "^5.5.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"handlebars": "^4.7.8",
|
||||
|
||||
234
backend/scripts/apply-tenant-export-lock-migration.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import "dotenv/config"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Pool } from "pg"
|
||||
|
||||
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||
|
||||
const maskConnectionString = (value: string) =>
|
||||
value.replace(/:\/\/([^:@]+):([^@]+)@/, "://$1:***@")
|
||||
|
||||
const logStep = (message: string) => {
|
||||
console.log(`[tenant-lock-migration] ${message}`)
|
||||
}
|
||||
|
||||
const terminateIdleBlockers = process.argv.includes("--terminate-idle-blockers")
|
||||
|
||||
const relationLocksQuery = (relationName: string) => sql`
|
||||
SELECT
|
||||
activity.pid,
|
||||
activity.state,
|
||||
activity.wait_event_type,
|
||||
activity.wait_event,
|
||||
now() - activity.query_start AS query_age,
|
||||
locks.mode,
|
||||
locks.granted,
|
||||
left(activity.query, 500) AS query
|
||||
FROM pg_locks locks
|
||||
JOIN pg_class relation ON relation.oid = locks.relation
|
||||
LEFT JOIN pg_stat_activity activity ON activity.pid = locks.pid
|
||||
WHERE relation.relname = ${relationName}
|
||||
ORDER BY locks.granted ASC, activity.query_start ASC NULLS LAST;
|
||||
`
|
||||
|
||||
const getRelationLocks = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||
const result = await db.execute(relationLocksQuery(relationName))
|
||||
return result.rows as Record<string, any>[]
|
||||
}
|
||||
|
||||
const printRelationLocks = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||
const rows = await getRelationLocks(db, relationName)
|
||||
|
||||
if (!rows.length) {
|
||||
logStep(`Keine aktiven Locks fuer ${relationName} gefunden`)
|
||||
return
|
||||
}
|
||||
|
||||
logStep(`Aktive Locks fuer ${relationName}:`)
|
||||
for (const row of rows) {
|
||||
console.log(JSON.stringify({
|
||||
pid: row.pid,
|
||||
state: row.state,
|
||||
waitEventType: row.wait_event_type,
|
||||
waitEvent: row.wait_event,
|
||||
queryAge: row.query_age,
|
||||
mode: row.mode,
|
||||
granted: row.granted,
|
||||
query: row.query,
|
||||
}, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
const terminateIdleRelationBlockers = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||
const rows = await getRelationLocks(db, relationName)
|
||||
const blockerPids = rows
|
||||
.filter((row) =>
|
||||
row.granted === true &&
|
||||
row.state === "idle in transaction" &&
|
||||
Number(row.pid) !== process.pid
|
||||
)
|
||||
.map((row) => Number(row.pid))
|
||||
.filter((pid) => Number.isFinite(pid))
|
||||
|
||||
if (!blockerPids.length) {
|
||||
logStep(`Keine idle-in-transaction Blocker fuer ${relationName} gefunden`)
|
||||
return false
|
||||
}
|
||||
|
||||
for (const pid of blockerPids) {
|
||||
logStep(`Beende idle-in-transaction DB-Session ${pid}`)
|
||||
await db.execute(sql`SELECT pg_terminate_backend(${pid})`)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const executeStatement = async (
|
||||
db: ReturnType<typeof drizzle>,
|
||||
statement: ReturnType<typeof sql>,
|
||||
) => {
|
||||
await db.execute(statement)
|
||||
}
|
||||
|
||||
const handleLockError = async (
|
||||
db: ReturnType<typeof drizzle>,
|
||||
relationName: string,
|
||||
) => {
|
||||
logStep(`Konnte Lock fuer ${relationName} nicht rechtzeitig bekommen`)
|
||||
await printRelationLocks(db, relationName)
|
||||
|
||||
if (!terminateIdleBlockers) return false
|
||||
|
||||
const terminated = await terminateIdleRelationBlockers(db, relationName)
|
||||
if (terminated) {
|
||||
logStep(`Retry nach beendeten idle-in-transaction Sessions fuer ${relationName}`)
|
||||
}
|
||||
|
||||
return terminated
|
||||
}
|
||||
|
||||
const executeStep = async (
|
||||
db: ReturnType<typeof drizzle>,
|
||||
message: string,
|
||||
statement: ReturnType<typeof sql>,
|
||||
lockRelationName?: string,
|
||||
) => {
|
||||
logStep(message)
|
||||
|
||||
try {
|
||||
await executeStatement(db, statement)
|
||||
} catch (err: any) {
|
||||
const code = err?.cause?.code || err?.code
|
||||
if (!lockRelationName || !["55P03", "57014"].includes(code)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
const shouldRetry = await handleLockError(db, lockRelationName)
|
||||
if (!shouldRetry) throw err
|
||||
|
||||
await executeStatement(db, statement)
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (terminateIdleBlockers) {
|
||||
logStep("Option --terminate-idle-blockers aktiv")
|
||||
}
|
||||
|
||||
let connectionString = process.env.DATABASE_URL
|
||||
|
||||
if (!connectionString) {
|
||||
logStep("DATABASE_URL nicht in process.env gefunden, lade Secrets")
|
||||
await loadSecrets()
|
||||
connectionString = secrets.DATABASE_URL
|
||||
} else {
|
||||
logStep("DATABASE_URL aus process.env gefunden")
|
||||
}
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL not configured")
|
||||
}
|
||||
|
||||
logStep(`Verbinde mit ${maskConnectionString(connectionString)}`)
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: 1,
|
||||
connectionTimeoutMillis: 10000,
|
||||
idleTimeoutMillis: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
const db = drizzle(pool)
|
||||
|
||||
logStep("Teste DB-Verbindung")
|
||||
await db.execute(sql`SELECT 1`)
|
||||
|
||||
logStep("Setze Lock- und Statement-Timeout")
|
||||
await db.execute(sql`SET lock_timeout = '10s'`)
|
||||
await db.execute(sql`SET statement_timeout = '2min'`)
|
||||
|
||||
await executeStep(db, "Erweitere tenant_export_jobs", sql`
|
||||
ALTER TABLE "tenant_export_jobs"
|
||||
ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant";
|
||||
`, "tenant_export_jobs")
|
||||
|
||||
await executeStep(db, "Erweitere tenants", sql`
|
||||
ALTER TABLE "tenants"
|
||||
ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid;
|
||||
`, "tenants")
|
||||
|
||||
await executeStep(db, "Pruefe Foreign Key", sql`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = 'public'
|
||||
AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tenants"
|
||||
ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk"
|
||||
FOREIGN KEY ("locked_by_export_job_id")
|
||||
REFERENCES "tenant_export_jobs"("id")
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`, "tenants")
|
||||
|
||||
await executeStep(db, "Pruefe Drizzle-Schema", sql`
|
||||
CREATE SCHEMA IF NOT EXISTS drizzle;
|
||||
`)
|
||||
|
||||
await executeStep(db, "Pruefe Drizzle-Migrationstabelle", sql`
|
||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
);
|
||||
`)
|
||||
|
||||
await executeStep(db, "Setze Drizzle-Journal auf 0055", sql`
|
||||
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
|
||||
SELECT 'manual-0055_tenant_export_maintenance_lock', 1784021669805
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM drizzle.__drizzle_migrations
|
||||
WHERE created_at >= 1784021669805
|
||||
);
|
||||
`)
|
||||
|
||||
logStep("Fertig")
|
||||
console.log("✅ Tenant export lock migration applied")
|
||||
} finally {
|
||||
logStep("Schließe DB-Verbindung")
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("❌ Tenant export lock migration failed")
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
67
backend/scripts/dedupe-auth-tenant-users.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import "dotenv/config"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Pool } from "pg"
|
||||
|
||||
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||
|
||||
async function run() {
|
||||
let connectionString = process.env.DATABASE_URL
|
||||
|
||||
if (!connectionString) {
|
||||
await loadSecrets()
|
||||
connectionString = secrets.DATABASE_URL
|
||||
}
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL not configured")
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: 1,
|
||||
connectionTimeoutMillis: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
const db = drizzle(pool)
|
||||
|
||||
const duplicates = await db.execute(sql`
|
||||
SELECT tenant_id, user_id, count(*)::int AS count
|
||||
FROM auth_tenant_users
|
||||
GROUP BY tenant_id, user_id
|
||||
HAVING count(*) > 1
|
||||
ORDER BY count(*) DESC;
|
||||
`)
|
||||
|
||||
console.log(`Gefundene doppelte Tenant-Zuordnungen: ${duplicates.rows.length}`)
|
||||
for (const row of duplicates.rows as Record<string, any>[]) {
|
||||
console.log(JSON.stringify(row))
|
||||
}
|
||||
|
||||
const deleted = await db.execute(sql`
|
||||
DELETE FROM auth_tenant_users duplicate
|
||||
USING auth_tenant_users keep
|
||||
WHERE duplicate.tenant_id = keep.tenant_id
|
||||
AND duplicate.user_id = keep.user_id
|
||||
AND duplicate.ctid > keep.ctid;
|
||||
`)
|
||||
|
||||
console.log(`Entfernte doppelte Zeilen: ${deleted.rowCount || 0}`)
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS auth_tenant_users_tenant_user_unique
|
||||
ON auth_tenant_users (tenant_id, user_id);
|
||||
`)
|
||||
|
||||
console.log("Unique Index auth_tenant_users_tenant_user_unique ist vorhanden")
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("Dedupe auth_tenant_users failed")
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
359
backend/scripts/delete-createddocument-range.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import "dotenv/config"
|
||||
import { Pool, type PoolClient } from "pg"
|
||||
|
||||
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||
|
||||
type Args = {
|
||||
tenantId?: number
|
||||
fromNumber?: string
|
||||
toNumber?: string
|
||||
fromId?: number
|
||||
toId?: number
|
||||
types: string[]
|
||||
execute: boolean
|
||||
unlinkRelated: boolean
|
||||
}
|
||||
|
||||
type TargetDocument = {
|
||||
id: number
|
||||
documentNumber: string | null
|
||||
type: string
|
||||
title: string | null
|
||||
state: string
|
||||
}
|
||||
|
||||
const DEFAULT_TYPES = ["invoices"]
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Löscht einen Bereich von Ausgangsbelegen und deren direkt verknüpfte DB-Dokumente.
|
||||
|
||||
Standardmäßig läuft das Skript als Dry-Run. Erst --execute löscht wirklich.
|
||||
|
||||
Beispiele:
|
||||
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010
|
||||
npm run invoices:delete-range -- --tenant 41 --from-id 1200 --to-id 1210 --execute
|
||||
npm run invoices:delete-range -- --tenant 41 --from RE-2026-001 --to RE-2026-010 --type invoices --type advanceInvoices --execute
|
||||
|
||||
Optionen:
|
||||
--tenant <id> Pflicht: Tenant-ID
|
||||
--from <nummer> Erste Rechnungsnummer im Bereich
|
||||
--to <nummer> Letzte Rechnungsnummer im Bereich
|
||||
--from-id <id> Erste Beleg-ID im Bereich
|
||||
--to-id <id> Letzte Beleg-ID im Bereich
|
||||
--type <type> Belegtyp, mehrfach möglich. Standard: invoices
|
||||
--execute Wirklich löschen. Ohne diese Option nur Vorschau.
|
||||
--unlink-related Externe Belege, die auf Zielbelege zeigen, werden entkoppelt.
|
||||
--help Hilfe anzeigen
|
||||
`)
|
||||
}
|
||||
|
||||
function readValue(args: string[], index: number, flag: string) {
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`Für ${flag} fehlt ein Wert.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function readNumber(args: string[], index: number, flag: string) {
|
||||
const value = Number(readValue(args, index, flag))
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${flag} muss eine positive Ganzzahl sein.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const parsed: Args = {
|
||||
types: [],
|
||||
execute: false,
|
||||
unlinkRelated: false,
|
||||
}
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printUsage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (arg === "--tenant") {
|
||||
parsed.tenantId = readNumber(argv, i, arg)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === "--from") {
|
||||
parsed.fromNumber = readValue(argv, i, arg)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === "--to") {
|
||||
parsed.toNumber = readValue(argv, i, arg)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === "--from-id") {
|
||||
parsed.fromId = readNumber(argv, i, arg)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === "--to-id") {
|
||||
parsed.toId = readNumber(argv, i, arg)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === "--type") {
|
||||
parsed.types.push(readValue(argv, i, arg))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === "--execute") {
|
||||
parsed.execute = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === "--unlink-related") {
|
||||
parsed.unlinkRelated = true
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`Unbekannte Option: ${arg}`)
|
||||
}
|
||||
|
||||
if (!parsed.tenantId) throw new Error("--tenant ist Pflicht.")
|
||||
|
||||
const hasNumberRange = parsed.fromNumber !== undefined || parsed.toNumber !== undefined
|
||||
const hasIdRange = parsed.fromId !== undefined || parsed.toId !== undefined
|
||||
|
||||
if (hasNumberRange && hasIdRange) {
|
||||
throw new Error("Bitte entweder Rechnungsnummernbereich oder ID-Bereich verwenden, nicht beides.")
|
||||
}
|
||||
|
||||
if (hasNumberRange && (!parsed.fromNumber || !parsed.toNumber)) {
|
||||
throw new Error("Für einen Rechnungsnummernbereich sind --from und --to Pflicht.")
|
||||
}
|
||||
|
||||
if (hasIdRange && (!parsed.fromId || !parsed.toId)) {
|
||||
throw new Error("Für einen ID-Bereich sind --from-id und --to-id Pflicht.")
|
||||
}
|
||||
|
||||
if (!hasNumberRange && !hasIdRange) {
|
||||
throw new Error("Bitte einen Bereich mit --from/--to oder --from-id/--to-id angeben.")
|
||||
}
|
||||
|
||||
if (parsed.fromNumber && parsed.toNumber && parsed.fromNumber > parsed.toNumber) {
|
||||
throw new Error("--from darf bei Textvergleich nicht größer als --to sein.")
|
||||
}
|
||||
|
||||
if (parsed.fromId && parsed.toId && parsed.fromId > parsed.toId) {
|
||||
throw new Error("--from-id darf nicht größer als --to-id sein.")
|
||||
}
|
||||
|
||||
if (parsed.types.length === 0) parsed.types = DEFAULT_TYPES
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function loadConnectionString() {
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
|
||||
|
||||
await loadSecrets()
|
||||
if (secrets.DATABASE_URL) return secrets.DATABASE_URL
|
||||
|
||||
throw new Error("DATABASE_URL ist nicht konfiguriert.")
|
||||
}
|
||||
|
||||
function buildTargetWhere(args: Args, startParam = 1) {
|
||||
const values: unknown[] = [args.tenantId, args.types]
|
||||
const clauses = [
|
||||
`"tenant" = $${startParam}`,
|
||||
`"type" = ANY($${startParam + 1}::text[])`,
|
||||
]
|
||||
|
||||
if (args.fromNumber && args.toNumber) {
|
||||
values.push(args.fromNumber, args.toNumber)
|
||||
clauses.push(`"documentNumber" >= $${startParam + 2}`)
|
||||
clauses.push(`"documentNumber" <= $${startParam + 3}`)
|
||||
} else {
|
||||
values.push(args.fromId, args.toId)
|
||||
clauses.push(`"id" >= $${startParam + 2}`)
|
||||
clauses.push(`"id" <= $${startParam + 3}`)
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
whereSql: clauses.join(" AND "),
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTargetDocuments(client: PoolClient, args: Args) {
|
||||
const target = buildTargetWhere(args)
|
||||
const result = await client.query<TargetDocument>(
|
||||
`
|
||||
SELECT "id", "documentNumber", "type", "title", "state"
|
||||
FROM "createddocuments"
|
||||
WHERE ${target.whereSql}
|
||||
ORDER BY "id"
|
||||
`,
|
||||
target.values
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async function countByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
|
||||
const result = await client.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::int AS "count" FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
|
||||
[targetIds]
|
||||
)
|
||||
|
||||
return Number(result.rows[0]?.count || 0)
|
||||
}
|
||||
|
||||
async function findExternalLinkedDocuments(client: PoolClient, targetIds: number[]) {
|
||||
const result = await client.query<TargetDocument>(
|
||||
`
|
||||
SELECT "id", "documentNumber", "type", "title", "state"
|
||||
FROM "createddocuments"
|
||||
WHERE "linkedDocument" = ANY($1::bigint[])
|
||||
AND NOT ("id" = ANY($1::bigint[]))
|
||||
ORDER BY "id"
|
||||
`,
|
||||
[targetIds]
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async function deleteByTarget(client: PoolClient, table: string, column: string, targetIds: number[]) {
|
||||
const result = await client.query(
|
||||
`DELETE FROM "${table}" WHERE "${column}" = ANY($1::bigint[])`,
|
||||
[targetIds]
|
||||
)
|
||||
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
function printTargets(targets: TargetDocument[]) {
|
||||
console.table(
|
||||
targets.map((item) => ({
|
||||
id: item.id,
|
||||
nummer: item.documentNumber,
|
||||
typ: item.type,
|
||||
status: item.state,
|
||||
titel: item.title,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const connectionString = await loadConnectionString()
|
||||
const pool = new Pool({ connectionString, max: 1 })
|
||||
const client = await pool.connect()
|
||||
|
||||
try {
|
||||
await client.query("BEGIN")
|
||||
|
||||
const targets = await selectTargetDocuments(client, args)
|
||||
if (targets.length === 0) {
|
||||
console.log("Keine passenden Rechnungen gefunden.")
|
||||
await client.query("ROLLBACK")
|
||||
return
|
||||
}
|
||||
|
||||
const targetIds = targets.map((item) => item.id)
|
||||
const [fileCount, historyCount, allocationCount, externalLinks] = await Promise.all([
|
||||
countByTarget(client, "files", "createddocument", targetIds),
|
||||
countByTarget(client, "historyitems", "createddocument", targetIds),
|
||||
countByTarget(client, "statementallocations", "cd_id", targetIds),
|
||||
findExternalLinkedDocuments(client, targetIds),
|
||||
])
|
||||
|
||||
console.log(`Tenant: ${args.tenantId}`)
|
||||
console.log(`Belegtypen: ${args.types.join(", ")}`)
|
||||
console.log(`Modus: ${args.execute ? "Löschen" : "Dry-Run"}`)
|
||||
console.log("")
|
||||
printTargets(targets)
|
||||
console.log("")
|
||||
console.log("Betroffene verknüpfte DB-Zeilen:")
|
||||
console.log(`- Dateien: ${fileCount}`)
|
||||
console.log(`- Historie: ${historyCount}`)
|
||||
console.log(`- Bank-Zuordnungen: ${allocationCount}`)
|
||||
|
||||
if (externalLinks.length > 0 && !args.unlinkRelated) {
|
||||
console.log("")
|
||||
console.log("Abbruch: Andere Belege verweisen auf Zielbelege.")
|
||||
console.log("Nutze --unlink-related, wenn diese Verknüpfungen vor dem Löschen entfernt werden sollen.")
|
||||
printTargets(externalLinks)
|
||||
await client.query("ROLLBACK")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!args.execute) {
|
||||
console.log("")
|
||||
console.log("Dry-Run abgeschlossen. Es wurde nichts gelöscht. Zum Löschen --execute ergänzen.")
|
||||
await client.query("ROLLBACK")
|
||||
return
|
||||
}
|
||||
|
||||
if (externalLinks.length > 0) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE "createddocuments"
|
||||
SET "linkedDocument" = NULL
|
||||
WHERE "linkedDocument" = ANY($1::bigint[])
|
||||
AND NOT ("id" = ANY($1::bigint[]))
|
||||
`,
|
||||
[targetIds]
|
||||
)
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE "createddocuments" SET "linkedDocument" = NULL WHERE "id" = ANY($1::bigint[])`,
|
||||
[targetIds]
|
||||
)
|
||||
|
||||
const deletedFiles = await deleteByTarget(client, "files", "createddocument", targetIds)
|
||||
const deletedHistory = await deleteByTarget(client, "historyitems", "createddocument", targetIds)
|
||||
const deletedAllocations = await deleteByTarget(client, "statementallocations", "cd_id", targetIds)
|
||||
|
||||
const deletedDocuments = await client.query(
|
||||
`DELETE FROM "createddocuments" WHERE "id" = ANY($1::bigint[])`,
|
||||
[targetIds]
|
||||
)
|
||||
|
||||
await client.query("COMMIT")
|
||||
|
||||
console.log("")
|
||||
console.log("Löschung abgeschlossen:")
|
||||
console.log(`- Rechnungen: ${deletedDocuments.rowCount || 0}`)
|
||||
console.log(`- Dateien: ${deletedFiles}`)
|
||||
console.log(`- Historie: ${deletedHistory}`)
|
||||
console.log(`- Bank-Zuordnungen: ${deletedAllocations}`)
|
||||
if (externalLinks.length > 0) {
|
||||
console.log(`- Entkoppelte externe Belege: ${externalLinks.length}`)
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK")
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("Fehler beim Löschen der Rechnungen:")
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,6 +1,13 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import dayjs from "dayjs"
|
||||
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
||||
import { loadFileBuffer } from "../../utils/fileBuffer"
|
||||
import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice"
|
||||
import { parseElectronicInvoice } from "../einvoice/parseElectronicInvoice"
|
||||
import {
|
||||
findElectronicInvoiceVendor,
|
||||
mapElectronicInvoiceToIncomingInvoice,
|
||||
} from "../einvoice/mapElectronicInvoice"
|
||||
|
||||
// Drizzle schema
|
||||
import {
|
||||
@@ -24,6 +31,61 @@ const formatInvoiceItemDescription = (item: any) => {
|
||||
return parts.join(" - ")
|
||||
}
|
||||
|
||||
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
||||
const itemInfo: any = {
|
||||
tenant: tenantId,
|
||||
state: "Vorbereitet",
|
||||
preparationSource: "gpt",
|
||||
}
|
||||
|
||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||
|
||||
const mapPayment: Record<string, string> = {
|
||||
"Direct Debit": "Einzug",
|
||||
"Transfer": "Überweisung",
|
||||
"Credit Card": "Kreditkarte",
|
||||
"Other": "Sonstiges",
|
||||
}
|
||||
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
||||
|
||||
if (data.invoice_items?.length > 0) {
|
||||
itemInfo.accounts = data.invoice_items
|
||||
.filter((item: any) => item.description || item.total !== null || item.total_without_tax !== null)
|
||||
.map((item: any) => {
|
||||
const total = typeof item.total === "number" ? item.total : null
|
||||
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||
const amountTax = total !== null && totalWithoutTax !== null
|
||||
? Number((total - totalWithoutTax).toFixed(2))
|
||||
: null
|
||||
|
||||
return {
|
||||
account: item.account_id,
|
||||
description: item.description,
|
||||
amountNet: totalWithoutTax,
|
||||
amountTax,
|
||||
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
||||
amountGross: total,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let description = ""
|
||||
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||
for (const item of data.invoice_items || []) {
|
||||
const line = formatInvoiceItemDescription(item)
|
||||
if (line) description += `${line}\n`
|
||||
}
|
||||
itemInfo.description = description.trim()
|
||||
|
||||
return itemInfo
|
||||
}
|
||||
|
||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
const processInvoices = async (tenantId:number) => {
|
||||
console.log("▶ Starting Incoming Invoice Preparation")
|
||||
@@ -85,75 +147,59 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
||||
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
||||
// -------------------------------------------------------------
|
||||
for (const file of filesRes) {
|
||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||
|
||||
const data = await getInvoiceDataFromGPT(server,file, tenantId)
|
||||
let fileData: Buffer
|
||||
|
||||
if (!data) {
|
||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||
try {
|
||||
fileData = await loadFileBuffer(file.path!)
|
||||
} catch (error) {
|
||||
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3.1 IncomingInvoice-Objekt vorbereiten
|
||||
// ---------------------------------------------------------
|
||||
let itemInfo: any = {
|
||||
tenant: tenantId,
|
||||
state: "Vorbereitet"
|
||||
}
|
||||
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
||||
|
||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||
let itemInfo: any = null
|
||||
|
||||
// Payment terms mapping
|
||||
const mapPayment: any = {
|
||||
"Direct Debit": "Einzug",
|
||||
"Transfer": "Überweisung",
|
||||
"Credit Card": "Kreditkarte",
|
||||
"Other": "Sonstiges",
|
||||
}
|
||||
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
||||
if (electronicInvoice) {
|
||||
server.log.info({
|
||||
fileId: file.id,
|
||||
container: electronicInvoice.container,
|
||||
syntax: electronicInvoice.syntax,
|
||||
attachmentName: electronicInvoice.attachmentName,
|
||||
}, "Strukturierte E-Rechnung erkannt.")
|
||||
|
||||
// 3.2 Positionszeilen konvertieren
|
||||
if (data.invoice_items?.length > 0) {
|
||||
itemInfo.accounts = data.invoice_items
|
||||
.filter(item => item.description || item.total !== null || item.total_without_tax !== null)
|
||||
.map(item => {
|
||||
const total = typeof item.total === "number" ? item.total : null
|
||||
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||
const amountTax = total !== null && totalWithoutTax !== null
|
||||
? Number((total - totalWithoutTax).toFixed(2))
|
||||
: null
|
||||
try {
|
||||
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
|
||||
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
|
||||
itemInfo = mapElectronicInvoiceToIncomingInvoice(
|
||||
parsedInvoice,
|
||||
tenantId,
|
||||
vendor?.id || null,
|
||||
electronicInvoice.container,
|
||||
electronicInvoice.attachmentName,
|
||||
)
|
||||
} catch (error) {
|
||||
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
|
||||
|
||||
return {
|
||||
account: item.account_id,
|
||||
description: item.description,
|
||||
amountNet: totalWithoutTax,
|
||||
amountTax,
|
||||
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
||||
amountGross: total,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3.3 Beschreibung generieren
|
||||
let description = ""
|
||||
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||
if (data.invoice_items) {
|
||||
for (const item of data.invoice_items) {
|
||||
const line = formatInvoiceItemDescription(item)
|
||||
if (line) description += `${line}\n`
|
||||
if (electronicInvoice.container === "xml") continue
|
||||
}
|
||||
}
|
||||
itemInfo.description = description.trim()
|
||||
|
||||
if (!itemInfo) {
|
||||
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
|
||||
|
||||
if (!data) {
|
||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||
continue
|
||||
}
|
||||
|
||||
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4️⃣ IncomingInvoice erstellen
|
||||
|
||||
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
decodePDFRawStream,
|
||||
PDFDict,
|
||||
PDFDocument,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
PDFRawStream,
|
||||
PDFString,
|
||||
} from "pdf-lib"
|
||||
|
||||
export type ElectronicInvoiceSyntax = "cii" | "ubl-invoice" | "ubl-credit-note"
|
||||
|
||||
export type ElectronicInvoiceDetection = {
|
||||
container: "pdf" | "xml"
|
||||
syntax: ElectronicInvoiceSyntax
|
||||
xml: Buffer
|
||||
attachmentName: string | null
|
||||
}
|
||||
|
||||
type FileLike = {
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
mimeType?: string | null
|
||||
}
|
||||
|
||||
const decodePdfText = (value: unknown): string | null => {
|
||||
if (value instanceof PDFString || value instanceof PDFHexString) {
|
||||
return value.decodeText()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => {
|
||||
const sample = xml.subarray(0, Math.min(xml.length, 64 * 1024)).toString("utf8")
|
||||
|
||||
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice(?:\s|>)/i.test(sample)) {
|
||||
return "cii"
|
||||
}
|
||||
|
||||
if (
|
||||
/<(?:[A-Za-z_][\w.-]*:)?Invoice(?:\s|>)/i.test(sample)
|
||||
&& /urn:oasis:names:specification:ubl:schema:xsd:Invoice-2/i.test(sample)
|
||||
) {
|
||||
return "ubl-invoice"
|
||||
}
|
||||
|
||||
if (
|
||||
/<(?:[A-Za-z_][\w.-]*:)?CreditNote(?:\s|>)/i.test(sample)
|
||||
&& /urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2/i.test(sample)
|
||||
) {
|
||||
return "ubl-credit-note"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const extractXmlFromFileSpec = (fileSpec: PDFDict): { name: string | null, xml: Buffer } | null => {
|
||||
const context = fileSpec.context
|
||||
const embeddedFiles = context.lookupMaybe(fileSpec.get(PDFName.of("EF")), PDFDict)
|
||||
|
||||
if (!embeddedFiles) return null
|
||||
|
||||
const stream = context.lookup(
|
||||
embeddedFiles.get(PDFName.of("UF")) || embeddedFiles.get(PDFName.of("F")),
|
||||
)
|
||||
|
||||
if (!(stream instanceof PDFRawStream)) return null
|
||||
|
||||
const name = decodePdfText(
|
||||
context.lookup(fileSpec.get(PDFName.of("UF")) || fileSpec.get(PDFName.of("F"))),
|
||||
)
|
||||
const contents = Buffer.from(decodePDFRawStream(stream).decode())
|
||||
|
||||
return { name, xml: contents }
|
||||
}
|
||||
|
||||
const detectEmbeddedInvoice = async (pdf: Buffer): Promise<ElectronicInvoiceDetection | null> => {
|
||||
let document: PDFDocument
|
||||
|
||||
try {
|
||||
document = await PDFDocument.load(pdf, {
|
||||
ignoreEncryption: true,
|
||||
updateMetadata: false,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const [, object] of document.context.enumerateIndirectObjects()) {
|
||||
if (!(object instanceof PDFDict)) continue
|
||||
|
||||
const type = object.get(PDFName.of("Type"))
|
||||
if (!(type instanceof PDFName) || type.asString() !== "/Filespec") continue
|
||||
|
||||
const embedded = extractXmlFromFileSpec(object)
|
||||
if (!embedded) continue
|
||||
|
||||
const syntax = detectXmlSyntax(embedded.xml)
|
||||
if (!syntax) continue
|
||||
|
||||
return {
|
||||
container: "pdf",
|
||||
syntax,
|
||||
xml: embedded.xml,
|
||||
attachmentName: embedded.name,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const hasPdfFileType = (file: FileLike, data: Buffer) => {
|
||||
const filename = String(file.name || file.path || "").toLowerCase()
|
||||
const mimeType = String(file.mimeType || "").toLowerCase().split(";")[0].trim()
|
||||
|
||||
return filename.endsWith(".pdf") || mimeType === "application/pdf" || data.subarray(0, 5).toString("ascii") === "%PDF-"
|
||||
}
|
||||
|
||||
export const detectElectronicInvoice = async (
|
||||
data: Buffer,
|
||||
file: FileLike,
|
||||
): Promise<ElectronicInvoiceDetection | null> => {
|
||||
if (hasPdfFileType(file, data)) {
|
||||
return detectEmbeddedInvoice(data)
|
||||
}
|
||||
|
||||
const beginsLikeXml = data.subarray(0, 1024).toString("utf8").replace(/^\uFEFF/, "").trimStart().startsWith("<")
|
||||
const syntax = beginsLikeXml ? detectXmlSyntax(data) : null
|
||||
|
||||
if (syntax) {
|
||||
return {
|
||||
container: "xml",
|
||||
syntax,
|
||||
xml: data,
|
||||
attachmentName: file.name || file.path?.split("/").pop() || null,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
93
backend/src/modules/einvoice/mapElectronicInvoice.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { vendors } from "../../../db/schema"
|
||||
import type { ParsedElectronicInvoice } from "./types"
|
||||
|
||||
const normalizeIdentifier = (value: unknown) => String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase()
|
||||
const normalizeName = (value: unknown) => String(value || "").normalize("NFKD").replace(/[^A-Za-z0-9]/g, "").toUpperCase()
|
||||
|
||||
const paymentTypeByCode: Record<string, string> = {
|
||||
"10": "Bar",
|
||||
"30": "Überweisung",
|
||||
"48": "Kreditkarte",
|
||||
"49": "Lastschrift",
|
||||
"57": "Sonstiges",
|
||||
"58": "Überweisung",
|
||||
"59": "Lastschrift",
|
||||
}
|
||||
|
||||
export const findElectronicInvoiceVendor = async (
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
invoice: ParsedElectronicInvoice,
|
||||
) => {
|
||||
const candidates = await server.db.select().from(vendors).where(and(
|
||||
eq(vendors.tenant, tenantId),
|
||||
eq(vendors.archived, false),
|
||||
))
|
||||
|
||||
const vatId = normalizeIdentifier(invoice.seller.vatId)
|
||||
if (vatId) {
|
||||
const vatMatches = candidates.filter((vendor: any) => normalizeIdentifier(vendor.infoData?.ustid) === vatId)
|
||||
if (vatMatches.length === 1) return vatMatches[0]
|
||||
}
|
||||
|
||||
const sellerName = normalizeName(invoice.seller.name)
|
||||
if (sellerName) {
|
||||
const nameMatches = candidates.filter((vendor) => normalizeName(vendor.name) === sellerName)
|
||||
if (nameMatches.length === 1) return nameMatches[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const mapElectronicInvoiceToIncomingInvoice = (
|
||||
invoice: ParsedElectronicInvoice,
|
||||
tenantId: number,
|
||||
vendorId: number | null,
|
||||
container: "pdf" | "xml",
|
||||
attachmentName: string | null,
|
||||
) => {
|
||||
const warnings = [...invoice.validation.warnings]
|
||||
if (!vendorId) warnings.push("Rechnungssteller konnte keinem Lieferanten eindeutig zugeordnet werden.")
|
||||
|
||||
const state = invoice.validation.errors.length > 0 || !vendorId ? "Prüfung erforderlich" : "Vorbereitet"
|
||||
const descriptionParts = [
|
||||
invoice.buyerReference ? `Käuferreferenz: ${invoice.buyerReference}` : null,
|
||||
...invoice.items.map((item) => item.description),
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
tenant: tenantId,
|
||||
state,
|
||||
vendor: vendorId,
|
||||
reference: invoice.invoiceNumber,
|
||||
date: invoice.invoiceDate,
|
||||
dueDate: invoice.dueDate,
|
||||
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
|
||||
description: descriptionParts.join("\n"),
|
||||
accounts: invoice.items.map((item) => ({
|
||||
account: null,
|
||||
description: item.description,
|
||||
amountNet: item.netAmount,
|
||||
amountTax: item.taxAmount,
|
||||
taxType: item.taxCategory === "AE" ? "13B" : item.taxRate === 0 ? "null" : String(item.taxRate),
|
||||
amountGross: item.grossAmount,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
unitCode: item.unitCode,
|
||||
})),
|
||||
preparationSource: "e-invoice",
|
||||
eInvoiceSyntax: invoice.syntax,
|
||||
eInvoiceProfile: invoice.profileId,
|
||||
eInvoiceValidation: {
|
||||
errors: invoice.validation.errors,
|
||||
warnings,
|
||||
totals: invoice.totals,
|
||||
seller: invoice.seller,
|
||||
invoiceType: invoice.invoiceType,
|
||||
container,
|
||||
attachmentName,
|
||||
},
|
||||
}
|
||||
}
|
||||
223
backend/src/modules/einvoice/parseElectronicInvoice.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { XMLParser } from "fast-xml-parser"
|
||||
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
||||
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
parseTagValue: false,
|
||||
trimValues: true,
|
||||
})
|
||||
|
||||
const array = <T>(value: T | T[] | null | undefined): T[] => value == null ? [] : Array.isArray(value) ? value : [value]
|
||||
|
||||
const text = (value: any): string | null => {
|
||||
if (value == null) return null
|
||||
if (typeof value === "string" || typeof value === "number") return String(value).trim() || null
|
||||
if (typeof value === "object" && value["#text"] != null) return text(value["#text"])
|
||||
return null
|
||||
}
|
||||
|
||||
const number = (value: any): number | null => {
|
||||
const raw = text(value)
|
||||
if (raw == null) return null
|
||||
|
||||
const parsed = Number(raw)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const roundMoney = (value: number) => Number(value.toFixed(2))
|
||||
|
||||
const date = (value: any): string | null => {
|
||||
const raw = text(value?.DateTimeString ?? value)
|
||||
if (!raw) return null
|
||||
|
||||
if (/^\d{8}$/.test(raw)) {
|
||||
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
|
||||
}
|
||||
|
||||
const match = raw.match(/^\d{4}-\d{2}-\d{2}/)
|
||||
return match?.[0] || null
|
||||
}
|
||||
|
||||
const findTaxRegistration = (party: any, schemeId: string) => {
|
||||
return text(array(party?.SpecifiedTaxRegistration).find((entry: any) => {
|
||||
return String(entry?.ID?.["@_schemeID"] || "").toUpperCase() === schemeId
|
||||
})?.ID)
|
||||
}
|
||||
|
||||
const ciiDescription = (line: any) => {
|
||||
const product = line?.SpecifiedTradeProduct || {}
|
||||
return [text(product.Name), text(product.Description)].filter(Boolean).join(" - ") || "Position"
|
||||
}
|
||||
|
||||
const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
||||
const transaction = root?.SupplyChainTradeTransaction || {}
|
||||
const agreement = transaction?.ApplicableHeaderTradeAgreement || {}
|
||||
const settlement = transaction?.ApplicableHeaderTradeSettlement || {}
|
||||
const monetary = settlement?.SpecifiedTradeSettlementHeaderMonetarySummation || {}
|
||||
const seller = agreement?.SellerTradeParty || {}
|
||||
const typeCode = text(root?.ExchangedDocument?.TypeCode)
|
||||
const sign = typeCode === "381" ? -1 : 1
|
||||
|
||||
const items: ElectronicInvoiceItem[] = array(transaction?.IncludedSupplyChainTradeLineItem).map((line: any) => {
|
||||
const delivery = line?.SpecifiedLineTradeDelivery || {}
|
||||
const lineSettlement = line?.SpecifiedLineTradeSettlement || {}
|
||||
const tradeTax = array(lineSettlement?.ApplicableTradeTax)[0] || {}
|
||||
const quantity = number(delivery?.BilledQuantity)
|
||||
const net = number(lineSettlement?.SpecifiedTradeSettlementLineMonetarySummation?.LineTotalAmount) || 0
|
||||
const taxRate = number(tradeTax?.RateApplicablePercent) || 0
|
||||
const signedNet = roundMoney(sign * net)
|
||||
const taxAmount = roundMoney(signedNet * taxRate / 100)
|
||||
|
||||
return {
|
||||
description: ciiDescription(line),
|
||||
quantity,
|
||||
unitCode: text(delivery?.BilledQuantity?.["@_unitCode"]),
|
||||
netAmount: signedNet,
|
||||
taxRate,
|
||||
taxCategory: text(tradeTax?.CategoryCode),
|
||||
taxAmount,
|
||||
grossAmount: roundMoney(signedNet + taxAmount),
|
||||
}
|
||||
})
|
||||
|
||||
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
|
||||
const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {}
|
||||
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
|
||||
|
||||
return {
|
||||
syntax: "cii",
|
||||
profileId: text(guideline),
|
||||
invoiceNumber: text(root?.ExchangedDocument?.ID),
|
||||
invoiceDate: date(root?.ExchangedDocument?.IssueDateTime),
|
||||
dueDate: date(paymentTerms?.DueDateDateTime),
|
||||
currency: text(settlement?.InvoiceCurrencyCode),
|
||||
invoiceType: typeCode === "381" ? "credit-note" : "invoice",
|
||||
seller: {
|
||||
name: text(seller?.Name),
|
||||
vatId: findTaxRegistration(seller, "VA"),
|
||||
taxNumber: findTaxRegistration(seller, "FC"),
|
||||
iban: text(paymentMeans?.PayeePartyCreditorFinancialAccount?.IBANID),
|
||||
bic: text(paymentMeans?.PayeeSpecifiedCreditorFinancialInstitution?.BICID),
|
||||
},
|
||||
buyerReference: text(agreement?.BuyerReference),
|
||||
paymentMeansCode: text(paymentMeans?.TypeCode),
|
||||
items,
|
||||
totals: {
|
||||
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
|
||||
tax: number(monetary?.TaxTotalAmount) != null ? roundMoney(sign * number(monetary.TaxTotalAmount)!) : null,
|
||||
gross: number(monetary?.GrandTotalAmount) != null ? roundMoney(sign * number(monetary.GrandTotalAmount)!) : null,
|
||||
payable: number(monetary?.DuePayableAmount) != null ? roundMoney(sign * number(monetary.DuePayableAmount)!) : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
|
||||
|
||||
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
|
||||
const isCreditNote = syntax === "ubl-credit-note"
|
||||
const sign = isCreditNote ? -1 : 1
|
||||
const supplier = root?.AccountingSupplierParty?.Party || {}
|
||||
const monetary = root?.LegalMonetaryTotal || {}
|
||||
const paymentMeans = array(root?.PaymentMeans)[0] || {}
|
||||
const sourceLines = array(isCreditNote ? root?.CreditNoteLine : root?.InvoiceLine)
|
||||
|
||||
const items: ElectronicInvoiceItem[] = sourceLines.map((line: any) => {
|
||||
const quantityValue = isCreditNote ? line?.CreditedQuantity : line?.InvoicedQuantity
|
||||
const taxCategory = line?.Item?.ClassifiedTaxCategory || {}
|
||||
const net = number(line?.LineExtensionAmount) || 0
|
||||
const taxRate = number(taxCategory?.Percent) || 0
|
||||
const signedNet = roundMoney(sign * net)
|
||||
const taxAmount = roundMoney(signedNet * taxRate / 100)
|
||||
|
||||
return {
|
||||
description: [text(line?.Item?.Name), text(line?.Item?.Description)].filter(Boolean).join(" - ") || "Position",
|
||||
quantity: number(quantityValue),
|
||||
unitCode: text(quantityValue?.["@_unitCode"]),
|
||||
netAmount: signedNet,
|
||||
taxRate,
|
||||
taxCategory: text(taxCategory?.ID),
|
||||
taxAmount,
|
||||
grossAmount: roundMoney(signedNet + taxAmount),
|
||||
}
|
||||
})
|
||||
|
||||
const taxSchemeIds = array(supplier?.PartyTaxScheme)
|
||||
const vatId = text(taxSchemeIds.find((entry: any) => text(entry?.TaxScheme?.ID)?.toUpperCase() === "VAT")?.CompanyID)
|
||||
|
||||
return {
|
||||
syntax,
|
||||
profileId: text(root?.CustomizationID) || text(root?.ProfileID),
|
||||
invoiceNumber: text(root?.ID),
|
||||
invoiceDate: date(root?.IssueDate),
|
||||
dueDate: date(root?.DueDate) || date(paymentMeans?.PaymentDueDate),
|
||||
currency: text(root?.DocumentCurrencyCode),
|
||||
invoiceType: isCreditNote ? "credit-note" : "invoice",
|
||||
seller: {
|
||||
name: ublPartyName(supplier),
|
||||
vatId,
|
||||
taxNumber: text(supplier?.PartyTaxScheme?.CompanyID),
|
||||
iban: text(paymentMeans?.PayeeFinancialAccount?.ID),
|
||||
bic: text(paymentMeans?.PayeeFinancialAccount?.FinancialInstitutionBranch?.ID),
|
||||
},
|
||||
buyerReference: text(root?.BuyerReference),
|
||||
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
|
||||
items,
|
||||
totals: {
|
||||
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,
|
||||
tax: number(root?.TaxTotal?.TaxAmount) != null ? roundMoney(sign * number(root.TaxTotal.TaxAmount)!) : null,
|
||||
gross: number(monetary?.TaxInclusiveAmount) != null ? roundMoney(sign * number(monetary.TaxInclusiveAmount)!) : null,
|
||||
payable: number(monetary?.PayableAmount) != null ? roundMoney(sign * number(monetary.PayableAmount)!) : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const validate = (invoice: Omit<ParsedElectronicInvoice, "validation">) => {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const tolerance = 0.02
|
||||
|
||||
if (!invoice.invoiceNumber) errors.push("Rechnungsnummer fehlt.")
|
||||
if (!invoice.invoiceDate) errors.push("Rechnungsdatum fehlt oder ist ungültig.")
|
||||
if (!invoice.currency) errors.push("Rechnungswährung fehlt.")
|
||||
if (!invoice.seller.name) errors.push("Name des Rechnungsstellers fehlt.")
|
||||
if (invoice.items.length === 0) errors.push("Die E-Rechnung enthält keine Rechnungspositionen.")
|
||||
|
||||
const calculatedLineNet = roundMoney(invoice.items.reduce((sum, item) => sum + item.netAmount, 0))
|
||||
const calculatedTax = roundMoney(invoice.items.reduce((sum, item) => sum + item.taxAmount, 0))
|
||||
|
||||
if (invoice.totals.lineNet != null && Math.abs(invoice.totals.lineNet - calculatedLineNet) > tolerance) {
|
||||
errors.push(`Positionssumme ${calculatedLineNet.toFixed(2)} stimmt nicht mit der Nettosumme ${invoice.totals.lineNet.toFixed(2)} überein.`)
|
||||
}
|
||||
if (invoice.totals.tax != null && Math.abs(invoice.totals.tax - calculatedTax) > tolerance) {
|
||||
warnings.push(`Berechnete Steuer ${calculatedTax.toFixed(2)} weicht von der Steuer-Gesamtsumme ${invoice.totals.tax.toFixed(2)} ab.`)
|
||||
}
|
||||
if (invoice.totals.gross != null && invoice.totals.lineNet != null && invoice.totals.tax != null) {
|
||||
const expectedGross = roundMoney(invoice.totals.lineNet + invoice.totals.tax)
|
||||
if (Math.abs(expectedGross - invoice.totals.gross) > tolerance) {
|
||||
errors.push(`Bruttosumme ${invoice.totals.gross.toFixed(2)} stimmt nicht mit Netto plus Steuer ${expectedGross.toFixed(2)} überein.`)
|
||||
}
|
||||
}
|
||||
if (!invoice.seller.vatId) warnings.push("Keine USt-ID des Rechnungsstellers enthalten.")
|
||||
if (!invoice.dueDate) warnings.push("Kein Fälligkeitsdatum enthalten.")
|
||||
for (const taxRate of new Set(invoice.items.map((item) => item.taxRate))) {
|
||||
if (![0, 7, 19].includes(taxRate)) warnings.push(`Steuersatz ${taxRate}% ist in FEDEO noch keinem Standard-Steuerschlüssel zugeordnet.`)
|
||||
}
|
||||
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
export const parseElectronicInvoice = (xml: Buffer, syntax: ElectronicInvoiceSyntax): ParsedElectronicInvoice => {
|
||||
const parsed = parser.parse(xml.toString("utf8"))
|
||||
const root = syntax === "cii"
|
||||
? parsed.CrossIndustryInvoice
|
||||
: syntax === "ubl-credit-note"
|
||||
? parsed.CreditNote
|
||||
: parsed.Invoice
|
||||
|
||||
if (!root) throw new Error(`XML-Wurzelelement für ${syntax} wurde nicht gefunden.`)
|
||||
|
||||
const invoice = syntax === "cii" ? parseCii(root) : parseUbl(root, syntax)
|
||||
return { ...invoice, validation: validate(invoice) }
|
||||
}
|
||||
42
backend/src/modules/einvoice/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
||||
|
||||
export type ElectronicInvoiceItem = {
|
||||
description: string
|
||||
quantity: number | null
|
||||
unitCode: string | null
|
||||
netAmount: number
|
||||
taxRate: number
|
||||
taxCategory: string | null
|
||||
taxAmount: number
|
||||
grossAmount: number
|
||||
}
|
||||
|
||||
export type ParsedElectronicInvoice = {
|
||||
syntax: ElectronicInvoiceSyntax
|
||||
profileId: string | null
|
||||
invoiceNumber: string | null
|
||||
invoiceDate: string | null
|
||||
dueDate: string | null
|
||||
currency: string | null
|
||||
invoiceType: "invoice" | "credit-note"
|
||||
seller: {
|
||||
name: string | null
|
||||
vatId: string | null
|
||||
taxNumber: string | null
|
||||
iban: string | null
|
||||
bic: string | null
|
||||
}
|
||||
buyerReference: string | null
|
||||
paymentMeansCode: string | null
|
||||
items: ElectronicInvoiceItem[]
|
||||
totals: {
|
||||
lineNet: number | null
|
||||
tax: number | null
|
||||
gross: number | null
|
||||
payable: number | null
|
||||
}
|
||||
validation: {
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
}
|
||||
@@ -405,12 +405,15 @@ async function getCloseData(server:FastifyInstance,item: any, tenant: any, units
|
||||
|
||||
console.log(item);
|
||||
|
||||
const [contact, customer, project, contract] = await Promise.all([
|
||||
const [contact, customer, project, contract, bankAccounts] = await Promise.all([
|
||||
fetchById(server, schema.contacts, item.contact),
|
||||
fetchById(server, schema.customers, item.customer),
|
||||
fetchById(server, schema.projects, item.project),
|
||||
fetchById(server, schema.contracts, item.contract),
|
||||
item.letterhead ? fetchById(server, schema.letterheads, item.letterhead) : null
|
||||
server.db
|
||||
.select()
|
||||
.from(schema.bankaccounts)
|
||||
.where(and(eq(schema.bankaccounts.tenant, tenant.id), eq(schema.bankaccounts.archived, false), eq(schema.bankaccounts.expired, false)))
|
||||
|
||||
]);
|
||||
|
||||
@@ -433,7 +436,8 @@ async function getCloseData(server:FastifyInstance,item: any, tenant: any, units
|
||||
contract, // Contract Object
|
||||
units, // Units Array
|
||||
products, // Products Array
|
||||
services // Services Array
|
||||
services, // Services Array
|
||||
bankAccounts // Tenant Bank Accounts
|
||||
);
|
||||
|
||||
|
||||
@@ -447,7 +451,10 @@ async function getCloseData(server:FastifyInstance,item: any, tenant: any, units
|
||||
// Formatiert Zahlen zu deutscher Währung
|
||||
function renderCurrency(value: any, currency = "€") {
|
||||
if (value === undefined || value === null) return "0,00 " + currency;
|
||||
return Number(value).toFixed(2).replace(".", ",") + " " + currency;
|
||||
return Number(value).toLocaleString("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}) + " " + currency;
|
||||
}
|
||||
|
||||
// Berechnet den Zeilenpreis (Menge * Preis * Rabatt)
|
||||
@@ -525,9 +532,14 @@ export function getDocumentDataBackend(
|
||||
contractData: any, // Vertrag
|
||||
units: any[], // Array aller Einheiten
|
||||
products: any[], // Array aller Produkte
|
||||
services: any[] // Array aller Services
|
||||
services: any[], // Array aller Services
|
||||
bankAccounts: any[] = [] // Array aller Bankkonten
|
||||
) {
|
||||
const businessInfo = tenant.businessInfo || {}; // Fallback falls leer
|
||||
const paymentQrBankAccount = bankAccounts.find((account: any) => !account.archived && !account.expired && account.iban) || null
|
||||
const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban
|
||||
const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic
|
||||
const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || tenant.name
|
||||
|
||||
// --- 1. Agriculture Logic ---
|
||||
// Prüfen ob 'extraModules' existiert, sonst leeres Array annehmen
|
||||
@@ -721,6 +733,13 @@ export function getDocumentDataBackend(
|
||||
},
|
||||
agriculture: itemInfo.agriculture,
|
||||
// Falls du AdvanceInvoices brauchst, musst du die Objekte hier übergeben oder leer lassen
|
||||
usedAdvanceInvoices: []
|
||||
usedAdvanceInvoices: [],
|
||||
paymentQr: paymentQrIban && itemInfo.payment_type === "transfer" ? {
|
||||
iban: paymentQrIban,
|
||||
bic: paymentQrBic,
|
||||
recipientName: paymentQrRecipient,
|
||||
amount: totals.totalGross,
|
||||
remittance: itemInfo.documentNumber || itemInfo.title,
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
authRolePermissions,
|
||||
authUsers,
|
||||
m2mApiKeys,
|
||||
tenants,
|
||||
} from "../../db/schema"
|
||||
|
||||
import { eq, and, inArray } from "drizzle-orm"
|
||||
@@ -23,6 +24,19 @@ export default fp(async (server: FastifyInstance) => {
|
||||
url === "/api/mcp" ||
|
||||
url.startsWith("/api/mcp/")
|
||||
|
||||
const isWriteMethod = (method: string) =>
|
||||
["POST", "PUT", "PATCH", "DELETE"].includes(method)
|
||||
|
||||
const isTenantLockAllowedRoute = (urlPath: string) =>
|
||||
urlPath === "/api/auth/me" ||
|
||||
urlPath === "/auth/me" ||
|
||||
urlPath === "/api/tenant/switch" ||
|
||||
urlPath === "/tenant/switch" ||
|
||||
urlPath.startsWith("/api/admin/") ||
|
||||
urlPath.startsWith("/admin/") ||
|
||||
urlPath.startsWith("/api/auth/") ||
|
||||
urlPath.startsWith("/auth/")
|
||||
|
||||
const authenticateMcpApiKey = async (apiKey: string) => {
|
||||
if (!apiKey.startsWith("fedeo_mcp_")) return false
|
||||
|
||||
@@ -150,6 +164,25 @@ export default fp(async (server: FastifyInstance) => {
|
||||
const tenantId = req.user.tenant_id
|
||||
const userId = req.user.user_id
|
||||
|
||||
if (isWriteMethod(req.method) && !isTenantLockAllowedRoute(urlPath)) {
|
||||
const [tenant] = await server.db
|
||||
.select({
|
||||
locked: tenants.locked,
|
||||
lockedByExportJobId: tenants.lockedByExportJobId,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1)
|
||||
|
||||
if (tenant?.locked === "maintenance_tenant") {
|
||||
return reply.code(423).send({
|
||||
error: "Tenant is locked for maintenance",
|
||||
locked: tenant.locked,
|
||||
lockedByExportJobId: tenant.lockedByExportJobId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 3️⃣ Rollen des Nutzers im Tenant holen
|
||||
// --------------------------------------------------------
|
||||
|
||||
@@ -267,8 +267,204 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
return `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip`;
|
||||
};
|
||||
|
||||
const lockTenantForJob = async (tenantId: number, jobId: string) => {
|
||||
const [tenant] = await server.db
|
||||
.select({
|
||||
locked: tenants.locked,
|
||||
lockedByExportJobId: tenants.lockedByExportJobId,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1);
|
||||
|
||||
if (!tenant) throw new Error("Tenant not found");
|
||||
if (tenant.lockedByExportJobId && tenant.lockedByExportJobId !== jobId) {
|
||||
throw new Error("Tenant ist bereits durch einen anderen Export oder Import gesperrt");
|
||||
}
|
||||
|
||||
await server.db
|
||||
.update(tenantExportJobs)
|
||||
.set({
|
||||
previousTenantLocked: tenant.locked,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tenantExportJobs.id, jobId));
|
||||
|
||||
await server.db
|
||||
.update(tenants)
|
||||
.set({
|
||||
locked: "maintenance_tenant",
|
||||
lockedByExportJobId: jobId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tenants.id, tenantId));
|
||||
};
|
||||
|
||||
const unlockTenantForJob = async (tenantId: number, jobId: string) => {
|
||||
const [job] = await server.db
|
||||
.select({
|
||||
previousTenantLocked: tenantExportJobs.previousTenantLocked,
|
||||
})
|
||||
.from(tenantExportJobs)
|
||||
.where(eq(tenantExportJobs.id, jobId))
|
||||
.limit(1);
|
||||
|
||||
await server.db
|
||||
.update(tenants)
|
||||
.set({
|
||||
locked: job?.previousTenantLocked || null,
|
||||
lockedByExportJobId: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(tenants.id, tenantId),
|
||||
eq(tenants.lockedByExportJobId, jobId)
|
||||
));
|
||||
};
|
||||
|
||||
const createTenantImportJob = async (tenantId: number, currentUserId: string, filename: string) => {
|
||||
const [job] = await server.db
|
||||
.insert(tenantExportJobs)
|
||||
.values({
|
||||
tenantId,
|
||||
createdBy: currentUserId,
|
||||
operation: "import",
|
||||
status: "running",
|
||||
filename,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
await lockTenantForJob(tenantId, job.id);
|
||||
|
||||
return job;
|
||||
};
|
||||
|
||||
const completeImportedTenantAccess = async (
|
||||
currentUser: { id: string; email: string },
|
||||
result: { tenantId: number }
|
||||
) => {
|
||||
const fallbackName = deriveNameFromEmail(currentUser.email);
|
||||
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
.values({
|
||||
tenant_id: result.tenantId,
|
||||
user_id: currentUser.id,
|
||||
created_by: currentUser.id,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
const [existingAdminProfile] = await server.db
|
||||
.select({ id: authProfiles.id })
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, result.tenantId),
|
||||
eq(authProfiles.user_id, currentUser.id)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingAdminProfile) {
|
||||
await server.db
|
||||
.insert(authProfiles)
|
||||
.values({
|
||||
tenant_id: result.tenantId,
|
||||
user_id: currentUser.id,
|
||||
first_name: fallbackName.first_name,
|
||||
last_name: fallbackName.last_name,
|
||||
email: currentUser.email,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
let matrixProvisioned = false;
|
||||
let matrixProvisioningError: string | null = null;
|
||||
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
|
||||
try {
|
||||
const matrix = matrixService(server);
|
||||
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
type: "general",
|
||||
});
|
||||
matrixProvisioned = true;
|
||||
} catch (err: any) {
|
||||
matrixProvisioningError = err?.message || String(err);
|
||||
server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matrixProvisioned,
|
||||
matrixProvisioningError,
|
||||
};
|
||||
};
|
||||
|
||||
const startTenantImportJob = async (
|
||||
jobId: string,
|
||||
targetTenantId: number,
|
||||
currentUser: { id: string; email: string },
|
||||
importData: { type: "archive"; archiveBuffer: Buffer } | { type: "json"; exportData: TenantFullExport }
|
||||
) => {
|
||||
try {
|
||||
await server.db
|
||||
.update(tenantExportJobs)
|
||||
.set({
|
||||
status: "running",
|
||||
filesDone: 0,
|
||||
filesTotal: 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tenantExportJobs.id, jobId));
|
||||
|
||||
const onProgress = async ({ done, total }: { done: number; total: number }) => {
|
||||
await server.db
|
||||
.update(tenantExportJobs)
|
||||
.set({
|
||||
filesDone: done,
|
||||
filesTotal: total,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tenantExportJobs.id, jobId));
|
||||
};
|
||||
|
||||
const result = importData.type === "archive"
|
||||
? await importTenantFullExportArchive(server, importData.archiveBuffer, { targetTenantId, onProgress })
|
||||
: await importTenantFullExport(server, importData.exportData, { targetTenantId, onProgress });
|
||||
|
||||
await completeImportedTenantAccess(currentUser, result);
|
||||
|
||||
await server.db
|
||||
.update(tenantExportJobs)
|
||||
.set({
|
||||
status: "ready",
|
||||
filesDone: 1,
|
||||
filesTotal: 1,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
error: null,
|
||||
})
|
||||
.where(eq(tenantExportJobs.id, jobId));
|
||||
} catch (err: any) {
|
||||
console.error("ERROR tenant import job:", err);
|
||||
await server.db
|
||||
.update(tenantExportJobs)
|
||||
.set({
|
||||
status: "failed",
|
||||
error: err?.message || String(err),
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tenantExportJobs.id, jobId));
|
||||
} finally {
|
||||
await unlockTenantForJob(targetTenantId, jobId);
|
||||
}
|
||||
};
|
||||
|
||||
const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => {
|
||||
try {
|
||||
await lockTenantForJob(tenantId, jobId);
|
||||
|
||||
await server.db
|
||||
.update(tenantExportJobs)
|
||||
.set({
|
||||
@@ -317,6 +513,8 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tenantExportJobs.id, jobId));
|
||||
} finally {
|
||||
await unlockTenantForJob(tenantId, jobId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,6 +537,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
.values({
|
||||
tenantId,
|
||||
createdBy: currentUserId,
|
||||
operation: "export",
|
||||
status: "queued",
|
||||
filename,
|
||||
updatedAt: new Date(),
|
||||
@@ -417,6 +616,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
short: tenants.short,
|
||||
createdAt: tenants.createdAt,
|
||||
locked: tenants.locked,
|
||||
lockedByExportJobId: tenants.lockedByExportJobId,
|
||||
})
|
||||
.from(tenants),
|
||||
server.db
|
||||
@@ -460,10 +660,16 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
})
|
||||
.from(authUserRoles),
|
||||
]);
|
||||
const uniqueMembershipRows = Array.from(
|
||||
new Map(membershipRows.map((membership) => [
|
||||
`${membership.tenant_id}:${membership.user_id}`,
|
||||
membership,
|
||||
])).values()
|
||||
);
|
||||
|
||||
const users = userRows.map((user) => {
|
||||
const profiles = profileRows.filter((profile) => profile.user_id === user.id);
|
||||
const memberships = membershipRows.filter((membership) => membership.user_id === user.id);
|
||||
const memberships = uniqueMembershipRows.filter((membership) => membership.user_id === user.id);
|
||||
const roleAssignments = roleAssignmentRows.filter((assignment) => assignment.user_id === user.id);
|
||||
const preferredProfile = profiles.find((profile) => profile.active) || profiles[0];
|
||||
const fallbackName = deriveNameFromEmail(user.email);
|
||||
@@ -483,7 +689,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
|
||||
const tenantsWithCounts = tenantRows.map((tenant) => ({
|
||||
...tenant,
|
||||
user_count: membershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
|
||||
user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -491,7 +697,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
tenants: tenantsWithCounts,
|
||||
roles: roleRows,
|
||||
unassignedProfiles: profileRows.filter((profile) => !profile.user_id),
|
||||
memberships: membershipRows,
|
||||
memberships: uniqueMembershipRows,
|
||||
roleAssignments: roleAssignmentRows,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -931,6 +1137,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
const body = req.body as {
|
||||
name?: string;
|
||||
short?: string;
|
||||
locked?: string | null;
|
||||
};
|
||||
|
||||
const name = body.name?.trim();
|
||||
@@ -954,6 +1161,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
short: tenants.short,
|
||||
createdAt: tenants.createdAt,
|
||||
locked: tenants.locked,
|
||||
lockedByExportJobId: tenants.lockedByExportJobId,
|
||||
});
|
||||
|
||||
await createTenantSeeds(createdTenant.id, currentUser.id);
|
||||
@@ -1027,6 +1235,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
const body = req.body as {
|
||||
name?: string;
|
||||
short?: string;
|
||||
locked?: string | null;
|
||||
};
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
@@ -1036,6 +1245,16 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
|
||||
if (typeof body.name === "string") updateData.name = body.name.trim();
|
||||
if (typeof body.short === "string") updateData.short = body.short.trim();
|
||||
if (Object.prototype.hasOwnProperty.call(body, "locked")) {
|
||||
const allowedLockedValues = new Set([null, "maintenance_tenant", "maintenance", "general", "no_subscription"]);
|
||||
const lockedValue = body.locked || null;
|
||||
if (!allowedLockedValues.has(lockedValue)) {
|
||||
return reply.code(400).send({ error: "Invalid locked value" });
|
||||
}
|
||||
|
||||
updateData.locked = lockedValue;
|
||||
updateData.lockedByExportJobId = null;
|
||||
}
|
||||
|
||||
const [updatedTenant] = await server.db
|
||||
.update(tenants)
|
||||
@@ -1047,6 +1266,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
short: tenants.short,
|
||||
createdAt: tenants.createdAt,
|
||||
locked: tenants.locked,
|
||||
lockedByExportJobId: tenants.lockedByExportJobId,
|
||||
});
|
||||
|
||||
if (!updatedTenant) {
|
||||
@@ -1144,6 +1364,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
return {
|
||||
exportId: job.id,
|
||||
tenantId: job.tenantId,
|
||||
operation: job.operation,
|
||||
status: job.status,
|
||||
filename: job.filename,
|
||||
fileSize: job.fileSize,
|
||||
@@ -1153,7 +1374,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
completedAt: job.completedAt,
|
||||
downloadUrl: job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null,
|
||||
downloadUrl: job.operation === "export" && job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/tenant-exports/:export_id:", err);
|
||||
@@ -1206,74 +1427,63 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
|
||||
const isMultipart = req.headers["content-type"]?.includes("multipart/form-data");
|
||||
let result;
|
||||
let targetTenantId: number | null = null;
|
||||
|
||||
if (isMultipart) {
|
||||
const data: any = await req.file();
|
||||
if (!data?.file) return reply.code(400).send({ error: "export file required" });
|
||||
|
||||
const archiveBuffer = await data.toBuffer();
|
||||
const targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
|
||||
targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
|
||||
|
||||
if (targetTenantId) {
|
||||
const importJob = await createTenantImportJob(targetTenantId, currentUser.id, data.filename || "tenant-import.zip");
|
||||
void startTenantImportJob(importJob.id, targetTenantId, currentUser, {
|
||||
type: "archive",
|
||||
archiveBuffer,
|
||||
});
|
||||
|
||||
return reply.code(202).send({
|
||||
importId: importJob.id,
|
||||
exportId: importJob.id,
|
||||
tenantId: targetTenantId,
|
||||
status: importJob.status,
|
||||
filename: importJob.filename,
|
||||
statusUrl: `/api/admin/tenant-exports/${importJob.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId });
|
||||
} else {
|
||||
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
|
||||
const exportData = "format" in body ? body : body.exportData;
|
||||
const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null;
|
||||
targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null;
|
||||
|
||||
if (!exportData) {
|
||||
return reply.code(400).send({ error: "exportData required" });
|
||||
}
|
||||
|
||||
if (targetTenantId) {
|
||||
const importJob = await createTenantImportJob(targetTenantId, currentUser.id, "tenant-import.json");
|
||||
void startTenantImportJob(importJob.id, targetTenantId, currentUser, {
|
||||
type: "json",
|
||||
exportData,
|
||||
});
|
||||
|
||||
return reply.code(202).send({
|
||||
importId: importJob.id,
|
||||
exportId: importJob.id,
|
||||
tenantId: targetTenantId,
|
||||
status: importJob.status,
|
||||
filename: importJob.filename,
|
||||
statusUrl: `/api/admin/tenant-exports/${importJob.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
result = await importTenantFullExport(server, exportData, { targetTenantId });
|
||||
}
|
||||
const fallbackName = deriveNameFromEmail(currentUser.email);
|
||||
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
.values({
|
||||
tenant_id: result.tenantId,
|
||||
user_id: currentUser.id,
|
||||
created_by: currentUser.id,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
const [existingAdminProfile] = await server.db
|
||||
.select({ id: authProfiles.id })
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, result.tenantId),
|
||||
eq(authProfiles.user_id, currentUser.id)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingAdminProfile) {
|
||||
await server.db
|
||||
.insert(authProfiles)
|
||||
.values({
|
||||
tenant_id: result.tenantId,
|
||||
user_id: currentUser.id,
|
||||
first_name: fallbackName.first_name,
|
||||
last_name: fallbackName.last_name,
|
||||
email: currentUser.email,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
let matrixProvisioned = false;
|
||||
let matrixProvisioningError: string | null = null;
|
||||
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
|
||||
try {
|
||||
const matrix = matrixService(server);
|
||||
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
type: "general",
|
||||
});
|
||||
matrixProvisioned = true;
|
||||
} catch (err: any) {
|
||||
matrixProvisioningError = err?.message || String(err);
|
||||
req.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
|
||||
}
|
||||
}
|
||||
const { matrixProvisioned, matrixProvisioningError } = await completeImportedTenantAccess(currentUser, result);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { generateRandomPassword, hashPassword } from "../../utils/password";
|
||||
import { sendMail } from "../../utils/mailer";
|
||||
import { secrets } from "../../utils/secrets";
|
||||
|
||||
import { authUsers } from "../../../db/schema";
|
||||
import { authTenantUsers } from "../../../db/schema";
|
||||
import { tenants } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createAccessToken,
|
||||
issueRefreshToken,
|
||||
revokeRefreshToken,
|
||||
rotateRefreshToken,
|
||||
} from "../../utils/authTokens";
|
||||
|
||||
export default async function authRoutes(server: FastifyInstance) {
|
||||
|
||||
@@ -61,11 +65,12 @@ export default async function authRoutes(server: FastifyInstance) {
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
password: { type: "string" },
|
||||
rememberMe: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const body = req.body as { email: string; password: string };
|
||||
const body = req.body as { email: string; password: string; rememberMe?: boolean };
|
||||
|
||||
let user: any = null;
|
||||
|
||||
@@ -122,27 +127,43 @@ export default async function authRoutes(server: FastifyInstance) {
|
||||
return reply.code(401).send({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
tenant_id: req.tenant?.id ?? null,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: "6h" }
|
||||
);
|
||||
const tenantId = req.tenant?.id ? Number(req.tenant.id) : null;
|
||||
const token = createAccessToken(user, tenantId);
|
||||
const refreshToken = await issueRefreshToken(server, user.id, tenantId);
|
||||
|
||||
reply.setCookie("token", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 6,
|
||||
...(body.rememberMe ? { maxAge: 60 * 60 * 6 } : {}),
|
||||
});
|
||||
|
||||
return { token };
|
||||
return { token, refreshToken };
|
||||
});
|
||||
|
||||
server.post("/auth/refresh", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Refresh a persistent session",
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["refreshToken"],
|
||||
properties: {
|
||||
refreshToken: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const { refreshToken } = req.body as { refreshToken: string };
|
||||
const refreshed = await rotateRefreshToken(server, refreshToken);
|
||||
|
||||
if (!refreshed) {
|
||||
return reply.code(401).send({ error: "Invalid or expired refresh token" });
|
||||
}
|
||||
|
||||
return refreshed;
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// LOGOUT
|
||||
@@ -153,6 +174,10 @@ export default async function authRoutes(server: FastifyInstance) {
|
||||
summary: "Logout User"
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken;
|
||||
if (refreshToken) {
|
||||
await revokeRefreshToken(server, refreshToken);
|
||||
}
|
||||
reply.clearCookie("token", {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
|
||||
@@ -69,7 +69,9 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
.innerJoin(tenants, eq(authTenantUsers.tenant_id, tenants.id))
|
||||
.where(eq(authTenantUsers.user_id, userId))
|
||||
|
||||
const tenantList = tenantRows ?? []
|
||||
const tenantList = Array.from(
|
||||
new Map((tenantRows ?? []).map((tenant) => [tenant.id, tenant])).values()
|
||||
)
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 3) ACTIVE TENANT
|
||||
|
||||
@@ -25,6 +25,7 @@ import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||
import { generateLiquidityForecast } from "../utils/liquidityForecast";
|
||||
import { getUnsupportedPdfCharacterMessage } from "../utils/pdfCharacterError";
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween)
|
||||
@@ -134,7 +135,15 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
return pdf // Fastify wandelt automatisch in JSON
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
reply.code(500).send({ error: "Failed to create PDF" })
|
||||
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
|
||||
if (unsupportedCharacterMessage) {
|
||||
return reply.code(422).send({
|
||||
error: unsupportedCharacterMessage,
|
||||
code: "UNSUPPORTED_PDF_CHARACTER",
|
||||
})
|
||||
}
|
||||
|
||||
return reply.code(500).send({ error: "Failed to create PDF" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -239,6 +239,13 @@ function isDateValue(value: any) {
|
||||
}
|
||||
|
||||
function normalizeCreatedDocumentPayload(payload: Record<string, any>) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "customSurchargePercentage")) {
|
||||
const customSurchargePercentage = Number(payload.customSurchargePercentage)
|
||||
payload.customSurchargePercentage = Number.isFinite(customSurchargePercentage)
|
||||
? customSurchargePercentage
|
||||
: 0
|
||||
}
|
||||
|
||||
const numberRelationFields = [
|
||||
"customer",
|
||||
"contact",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||
import { rotateRefreshToken } from "../utils/authTokens"
|
||||
|
||||
|
||||
export default async function tenantRoutes(server: FastifyInstance) {
|
||||
@@ -65,7 +66,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const { tenant_id } = req.body as { tenant_id: string }
|
||||
const { tenant_id, refreshToken } = req.body as { tenant_id: string; refreshToken?: string }
|
||||
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
|
||||
|
||||
// prüfen ob der User zu diesem Tenant gehört
|
||||
@@ -81,7 +82,22 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
return reply.code(403).send({ error: "Not a member of this tenant" })
|
||||
}
|
||||
|
||||
// JWT neu erzeugen
|
||||
if (refreshToken) {
|
||||
const session = await rotateRefreshToken(
|
||||
server,
|
||||
refreshToken,
|
||||
Number(tenant_id),
|
||||
req.user.user_id
|
||||
)
|
||||
|
||||
if (!session) {
|
||||
return reply.code(401).send({ error: "Invalid or expired refresh token" })
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// Abwärtskompatibilität für Clients ohne persistente Session
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: req.user.user_id,
|
||||
|
||||
93
backend/src/utils/authTokens.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq, gt, isNull } from "drizzle-orm"
|
||||
|
||||
import { authRefreshTokens, authUsers } from "../../db/schema"
|
||||
import { secrets } from "./secrets"
|
||||
|
||||
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 6
|
||||
const REFRESH_TOKEN_TTL_DAYS = 90
|
||||
|
||||
export function createAccessToken(user: { id: string; email: string }, tenantId: number | null) {
|
||||
return jwt.sign(
|
||||
{
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
tenant_id: tenantId,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: ACCESS_TOKEN_TTL_SECONDS }
|
||||
)
|
||||
}
|
||||
|
||||
export function hashRefreshToken(token: string) {
|
||||
return createHash("sha256").update(token, "utf8").digest("hex")
|
||||
}
|
||||
|
||||
export async function issueRefreshToken(
|
||||
server: FastifyInstance,
|
||||
userId: string,
|
||||
tenantId: number | null
|
||||
) {
|
||||
const token = randomBytes(48).toString("base64url")
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
|
||||
await server.db.insert(authRefreshTokens).values({
|
||||
userId,
|
||||
tenantId,
|
||||
tokenHash: hashRefreshToken(token),
|
||||
expiresAt,
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
export async function rotateRefreshToken(
|
||||
server: FastifyInstance,
|
||||
token: string,
|
||||
tenantOverride?: number | null,
|
||||
expectedUserId?: string
|
||||
) {
|
||||
const tokenHash = hashRefreshToken(token)
|
||||
const [session] = await server.db
|
||||
.update(authRefreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(
|
||||
eq(authRefreshTokens.tokenHash, tokenHash),
|
||||
isNull(authRefreshTokens.revokedAt),
|
||||
gt(authRefreshTokens.expiresAt, new Date())
|
||||
))
|
||||
.returning({
|
||||
id: authRefreshTokens.id,
|
||||
userId: authRefreshTokens.userId,
|
||||
tenantId: authRefreshTokens.tenantId,
|
||||
})
|
||||
|
||||
if (!session) return null
|
||||
if (expectedUserId && session.userId !== expectedUserId) return null
|
||||
|
||||
const [user] = await server.db
|
||||
.select({ id: authUsers.id, email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, session.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const tenantId = tenantOverride === undefined ? session.tenantId : tenantOverride
|
||||
const refreshToken = await issueRefreshToken(server, session.userId, tenantId)
|
||||
const accessToken = createAccessToken(user, tenantId)
|
||||
|
||||
return { token: accessToken, refreshToken }
|
||||
}
|
||||
|
||||
export async function revokeRefreshToken(server: FastifyInstance, token: string) {
|
||||
await server.db
|
||||
.update(authRefreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(
|
||||
eq(authRefreshTokens.tokenHash, hashRefreshToken(token)),
|
||||
isNull(authRefreshTokens.revokedAt)
|
||||
))
|
||||
}
|
||||
26
backend/src/utils/fileBuffer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { s3 } from "./s3"
|
||||
import { secrets } from "./secrets"
|
||||
|
||||
export const streamToBuffer = async (stream: any): Promise<Buffer> => {
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
export const loadFileBuffer = async (path: string): Promise<Buffer> => {
|
||||
const response = await s3.send(new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: path,
|
||||
}))
|
||||
|
||||
if (!response.Body) {
|
||||
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
|
||||
}
|
||||
|
||||
return streamToBuffer(response.Body)
|
||||
}
|
||||
@@ -2,12 +2,11 @@ import dayjs from "dayjs";
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
import { s3 } from "./s3";
|
||||
import { secrets } from "./secrets";
|
||||
import { storeExtractedTextForFile } from "./documentText";
|
||||
import { loadFileBuffer } from "./fileBuffer";
|
||||
import { secrets } from "./secrets";
|
||||
|
||||
// Drizzle schema
|
||||
import { vendors, accounts, tenants } from "../../db/schema";
|
||||
@@ -27,18 +26,6 @@ export const initOpenAi = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// STREAM → BUFFER
|
||||
// ---------------------------------------------------------
|
||||
async function streamToBuffer(stream: any): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// GPT RESPONSE FORMAT (Zod Schema)
|
||||
// ---------------------------------------------------------
|
||||
@@ -93,7 +80,8 @@ const InstructionFormat = z.object({
|
||||
export const getInvoiceDataFromGPT = async function (
|
||||
server: FastifyInstance,
|
||||
file: any,
|
||||
tenantId: number
|
||||
tenantId: number,
|
||||
suppliedFileData?: Buffer,
|
||||
) {
|
||||
await initOpenAi();
|
||||
|
||||
@@ -109,13 +97,7 @@ export const getInvoiceDataFromGPT = async function (
|
||||
let fileData: Buffer;
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: file.path,
|
||||
});
|
||||
|
||||
const response: any = await s3.send(command);
|
||||
fileData = await streamToBuffer(response.Body);
|
||||
fileData = suppliedFileData || await loadFileBuffer(file.path);
|
||||
} catch (err) {
|
||||
console.log(`❌ S3 Download failed for file ${file.id}`, err);
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {FastifyInstance} from "fastify";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { s3 } from "./s3";
|
||||
import { secrets } from "./secrets";
|
||||
import bwipjs from "bwip-js"
|
||||
|
||||
const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
|
||||
/*
|
||||
@@ -25,6 +26,65 @@ const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getRightAlignedX = (rightEdgeMm: number, text: string, size: number, font: any, page: any) => {
|
||||
return getCoordinatesForPDFLib(rightEdgeMm, 0, page).x - font.widthOfTextAtSize(text, size)
|
||||
}
|
||||
|
||||
const normalizePaymentQrText = (value: any, maxLength: number) => {
|
||||
return String(value || "")
|
||||
.replace(/\r?\n/g, " ")
|
||||
.replace(/[^\x20-\x7EÄÖÜäöüß]/g, "")
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
|
||||
const normalizeIban = (value: any) => String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||
|
||||
const getPaymentQrAmount = (value: any) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value
|
||||
|
||||
const normalized = String(value || "")
|
||||
.replace(/[^\d,.-]/g, "")
|
||||
.replace(/\./g, "")
|
||||
.replace(",", ".")
|
||||
|
||||
const amount = Number(normalized)
|
||||
|
||||
return Number.isFinite(amount) ? amount : 0
|
||||
}
|
||||
|
||||
const getPaymentQrCodeBuffer = async (invoiceData: any) => {
|
||||
if (invoiceData?.payment_type && invoiceData.payment_type !== "transfer") return null
|
||||
|
||||
const paymentQr = invoiceData?.paymentQr || {}
|
||||
const iban = normalizeIban(paymentQr.iban)
|
||||
const recipientName = normalizePaymentQrText(paymentQr.recipientName || paymentQr.name, 70)
|
||||
const amount = getPaymentQrAmount(paymentQr.amount ?? invoiceData?.total?.totalSumToPay)
|
||||
|
||||
if (!iban || !recipientName || amount <= 0) return null
|
||||
|
||||
const epcPayload = [
|
||||
"BCD",
|
||||
"002",
|
||||
"1",
|
||||
"SCT",
|
||||
normalizePaymentQrText(paymentQr.bic, 11),
|
||||
recipientName,
|
||||
iban,
|
||||
`EUR${amount.toFixed(2)}`,
|
||||
"",
|
||||
normalizePaymentQrText(paymentQr.remittance || invoiceData?.documentNumber || invoiceData?.title, 140),
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
return bwipjs.toBuffer({
|
||||
bcid: "qrcode",
|
||||
text: epcPayload,
|
||||
scale: 4,
|
||||
includetext: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const getBackgroundSourceBuffer = async (server:FastifyInstance, path:string) => {
|
||||
|
||||
@@ -66,6 +126,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold)
|
||||
const paymentQrCodeBuffer = await getPaymentQrCodeBuffer(invoiceData)
|
||||
const paymentQrCode = paymentQrCodeBuffer ? await pdfDoc.embedPng(paymentQrCodeBuffer) : null
|
||||
const mm = 2.83
|
||||
const paymentQrLayout = {
|
||||
sizeMm: 32,
|
||||
rightMarginMm: 20,
|
||||
bottomMarginMm: 40,
|
||||
labelGapMm: 5,
|
||||
safeGapMm: 5,
|
||||
}
|
||||
|
||||
let pages = []
|
||||
let pageCounter = 1
|
||||
@@ -77,6 +147,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
const firstPageBackground = await pdfDoc.embedPage(backgroudPdf.getPages()[0])
|
||||
const secondPageBackground = await pdfDoc.embedPage(backgroudPdf.getPages()[backgroudPdf.getPages().length > 1 ? 1 : 0])
|
||||
|
||||
const getPaymentQrX = (page: any) => {
|
||||
return page.getWidth()
|
||||
- paymentQrLayout.rightMarginMm * mm
|
||||
- paymentQrLayout.sizeMm * mm
|
||||
}
|
||||
|
||||
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
|
||||
if (!paymentQrCode) return 500
|
||||
|
||||
return Math.max(
|
||||
240,
|
||||
getPaymentQrX(page) - startX - paymentQrLayout.safeGapMm * mm
|
||||
)
|
||||
}
|
||||
|
||||
//
|
||||
const page1 = pdfDoc.addPage()
|
||||
|
||||
@@ -372,8 +457,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
pages[pageCounter - 1].drawText("Einheitspreis", {
|
||||
...getCoordinatesForPDFLib(150, 137, page1),
|
||||
const unitPriceHeader = "Einheitspreis"
|
||||
pages[pageCounter - 1].drawText(unitPriceHeader, {
|
||||
y: getCoordinatesForPDFLib(150, 137, page1).y,
|
||||
x: getRightAlignedX(177, unitPriceHeader, 12, fontBold, page1),
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 12,
|
||||
@@ -399,6 +486,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
let pageIndex = 0
|
||||
|
||||
const getTitleKey = (row) => `${row.pos} - ${row.text}`
|
||||
const getTransferSumText = (row) => {
|
||||
const transferSum = invoiceData.total.titleSumsTransfer[getTitleKey(row)]
|
||||
|
||||
return transferSum ? `Übertrag: ${transferSum}` : null
|
||||
}
|
||||
|
||||
invoiceData.rows.forEach((row, index) => {
|
||||
|
||||
@@ -502,7 +595,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
maxWidth: 240
|
||||
})
|
||||
pages[pageCounter - 1].drawText(row.price, {
|
||||
...getCoordinatesForPDFLib(150, rowHeight, page1),
|
||||
y: getCoordinatesForPDFLib(150, rowHeight, page1).y,
|
||||
x: getRightAlignedX(177, row.price, 10, font, page1),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 10,
|
||||
@@ -579,18 +673,20 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
console.log(invoiceData.rows[index + 1])
|
||||
|
||||
if (invoiceData.rows[index + 1].mode === 'title') {
|
||||
let transferSumText = `Übertrag: ${invoiceData.total.titleSumsTransfer[Object.keys(invoiceData.total.titleSums)[invoiceData.rows[index + 1].pos - 2]]}`
|
||||
pages[pageCounter - 1].drawText(transferSumText, {
|
||||
y: getCoordinatesForPDFLib(21, rowHeight - 2, page1).y,
|
||||
x: getCoordinatesForPDFLib(21, rowHeight - 2, page1).x + 500 - fontBold.widthOfTextAtSize(transferSumText, 10),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 10,
|
||||
opacity: 1,
|
||||
maxWidth: 240,
|
||||
font: fontBold
|
||||
})
|
||||
if (invoiceData.rows[index + 1]?.mode === 'title') {
|
||||
const transferSumText = getTransferSumText(invoiceData.rows[index + 1])
|
||||
if (transferSumText) {
|
||||
pages[pageCounter - 1].drawText(transferSumText, {
|
||||
y: getCoordinatesForPDFLib(21, rowHeight - 2, page1).y,
|
||||
x: getCoordinatesForPDFLib(21, rowHeight - 2, page1).x + 500 - fontBold.widthOfTextAtSize(transferSumText, 10),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 10,
|
||||
opacity: 1,
|
||||
maxWidth: 240,
|
||||
font: fontBold
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -681,8 +777,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
page.drawText("Einheitspreis", {
|
||||
...getCoordinatesForPDFLib(150, 22, page1),
|
||||
const unitPriceHeader = "Einheitspreis"
|
||||
page.drawText(unitPriceHeader, {
|
||||
y: getCoordinatesForPDFLib(150, 22, page1).y,
|
||||
x: getRightAlignedX(177, unitPriceHeader, 12, fontBold, page1),
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 12,
|
||||
@@ -713,17 +811,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
if (index === 0 || pageIndex === 0) {
|
||||
rowHeight += 3
|
||||
} else {
|
||||
let transferSumText = `Übertrag: ${invoiceData.total.titleSumsTransfer[Object.keys(invoiceData.total.titleSums)[row.pos - 2]]}`
|
||||
pages[pageCounter - 1].drawText(transferSumText, {
|
||||
y: getCoordinatesForPDFLib(21, rowHeight - 2, page1).y,
|
||||
x: getCoordinatesForPDFLib(21, rowHeight - 2, page1).x + 500 - fontBold.widthOfTextAtSize(transferSumText, 10),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 10,
|
||||
opacity: 1,
|
||||
maxWidth: 240,
|
||||
font: fontBold
|
||||
})
|
||||
const transferSumText = getTransferSumText(row)
|
||||
if (transferSumText) {
|
||||
pages[pageCounter - 1].drawText(transferSumText, {
|
||||
y: getCoordinatesForPDFLib(21, rowHeight - 2, page1).y,
|
||||
x: getCoordinatesForPDFLib(21, rowHeight - 2, page1).x + 500 - fontBold.widthOfTextAtSize(transferSumText, 10),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 10,
|
||||
opacity: 1,
|
||||
maxWidth: 240,
|
||||
font: fontBold
|
||||
})
|
||||
}
|
||||
|
||||
pages[pageCounter - 1].drawLine({
|
||||
start: getCoordinatesForPDFLib(20, rowHeight, page1),
|
||||
@@ -735,6 +835,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
rowHeight += 5
|
||||
}
|
||||
|
||||
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
|
||||
const titleTextSize = titleLevel === 1 ? 12 : titleLevel === 2 ? 11 : 10
|
||||
const titleTextX = 35 + ((titleLevel - 1) * 7)
|
||||
|
||||
pages[pageCounter - 1].drawText(String(row.pos), {
|
||||
...getCoordinatesForPDFLib(21, rowHeight, page1),
|
||||
size: 10,
|
||||
@@ -746,16 +850,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
})
|
||||
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 60).join("\n"), {
|
||||
...getCoordinatesForPDFLib(35, rowHeight, page1),
|
||||
size: 12,
|
||||
...getCoordinatesForPDFLib(titleTextX, rowHeight, page1),
|
||||
size: titleTextSize,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 12,
|
||||
lineHeight: titleTextSize,
|
||||
opacity: 1,
|
||||
maxWidth: 500,
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
rowHeight += splitStringBySpace(row.text, 60).length * 4.5
|
||||
rowHeight += splitStringBySpace(row.text, 60).length * (titleLevel === 1 ? 4.5 : 4)
|
||||
} else if (row.mode === 'text') {
|
||||
if (index === 0 || pageIndex === 0) {
|
||||
rowHeight += 3
|
||||
@@ -893,14 +997,42 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
}
|
||||
|
||||
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
|
||||
|
||||
pages[pageCounter - 1].drawText(invoiceData.endText, {
|
||||
...getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1),
|
||||
...endTextPosition,
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 10,
|
||||
opacity: 1,
|
||||
maxWidth: 500
|
||||
maxWidth: getTextWidthBeforePaymentQr(pages[pageCounter - 1], endTextPosition.x)
|
||||
})
|
||||
}
|
||||
|
||||
if (paymentQrCode) {
|
||||
const lastPage = pages[pages.length - 1]
|
||||
const qrSize = paymentQrLayout.sizeMm * mm
|
||||
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
||||
const labelGap = paymentQrLayout.labelGapMm * mm
|
||||
const qrX = getPaymentQrX(lastPage)
|
||||
const qrY = bottomMargin
|
||||
|
||||
lastPage.drawText("Überweisung per QR-Code", {
|
||||
x: qrX,
|
||||
y: qrY + qrSize + labelGap,
|
||||
size: 8,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 8,
|
||||
opacity: 1,
|
||||
maxWidth: qrSize,
|
||||
font: fontBold,
|
||||
})
|
||||
|
||||
lastPage.drawImage(paymentQrCode, {
|
||||
x: qrX,
|
||||
y: qrY,
|
||||
width: qrSize,
|
||||
height: qrSize,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
79
backend/src/utils/pdfCharacterError.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
const PDF_CHARACTER_ERROR_PATTERN = /WinAnsi cannot encode "[\s\S]+?" \(0x([0-9a-f]+)\)/i
|
||||
|
||||
const DOCUMENT_FIELD_LABELS: Record<string, string> = {
|
||||
title: "Dokumenttitel",
|
||||
description: "Dokumentbeschreibung",
|
||||
startText: "Einleitungstext",
|
||||
endText: "Schlusstext",
|
||||
text: "Bezeichnung",
|
||||
descriptionText: "Beschreibung",
|
||||
unit: "Einheit",
|
||||
}
|
||||
|
||||
type CharacterLocation = {
|
||||
label: string
|
||||
characterPosition: number
|
||||
}
|
||||
|
||||
const labelForPath = (path: Array<string | number>, root: any): string => {
|
||||
if (path[0] === "rows" && typeof path[1] === "number") {
|
||||
const row = root?.rows?.[path[1]]
|
||||
const position = row?.pos ? `Position ${row.pos}` : `Dokumentzeile ${path[1] + 1}`
|
||||
const field = DOCUMENT_FIELD_LABELS[String(path[path.length - 1])] || String(path[path.length - 1])
|
||||
return `${position}, ${field}`
|
||||
}
|
||||
|
||||
const field = String(path[path.length - 1])
|
||||
return DOCUMENT_FIELD_LABELS[field] || field
|
||||
}
|
||||
|
||||
const findCharacterLocations = (
|
||||
value: unknown,
|
||||
unsupportedCharacter: string,
|
||||
root: any,
|
||||
path: Array<string | number> = [],
|
||||
): CharacterLocation[] => {
|
||||
if (typeof value === "string") {
|
||||
const characters = Array.from(value)
|
||||
return characters.flatMap((character, index) =>
|
||||
character === unsupportedCharacter
|
||||
? [{ label: labelForPath(path, root), characterPosition: index + 1 }]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry, index) =>
|
||||
findCharacterLocations(entry, unsupportedCharacter, root, [...path, index])
|
||||
)
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value).flatMap(([key, entry]) =>
|
||||
findCharacterLocations(entry, unsupportedCharacter, root, [...path, key])
|
||||
)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export const getUnsupportedPdfCharacterMessage = (error: unknown, documentData: any): string | null => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const match = errorMessage.match(PDF_CHARACTER_ERROR_PATTERN)
|
||||
if (!match) return null
|
||||
|
||||
const numericCodePoint = Number.parseInt(match[1], 16)
|
||||
if (!Number.isSafeInteger(numericCodePoint) || numericCodePoint > 0x10FFFF) return null
|
||||
|
||||
const unsupportedCharacter = String.fromCodePoint(numericCodePoint)
|
||||
const [location] = findCharacterLocations(documentData, unsupportedCharacter, documentData)
|
||||
const codePoint = numericCodePoint
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(4, "0")
|
||||
const locationText = location
|
||||
? ` Fundstelle: ${location.label}, Zeichen ${location.characterPosition}.`
|
||||
: ""
|
||||
|
||||
return `Das Zeichen „${unsupportedCharacter}“ (Unicode U+${codePoint}) wird in PDF-Dokumenten nicht unterstützt.${locationText} Bitte ersetze oder entferne das Zeichen.`
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
|
||||
export const renderAsCurrency = (value: string | number,currencyString = "€") => {
|
||||
return `${Number(value).toFixed(2).replace(".",",")} ${currencyString}`
|
||||
const formattedValue = Number(value).toLocaleString("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
|
||||
return `${formattedValue} ${currencyString}`
|
||||
}
|
||||
|
||||
export const splitStringBySpace = (input:string,maxSplitLength:number,removeLinebreaks = false) => {
|
||||
@@ -48,4 +53,4 @@ export const splitStringBySpace = (input:string,maxSplitLength:number,removeLine
|
||||
})
|
||||
|
||||
return returnSplitStrings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ type ImportResult = {
|
||||
|
||||
type ImportOptions = {
|
||||
targetTenantId?: number | null
|
||||
onProgress?: (progress: { done: number; total: number; message?: string }) => Promise<void> | void
|
||||
}
|
||||
|
||||
type BuildTenantFullExportOptions = {
|
||||
@@ -264,6 +265,22 @@ const archiveEntryPathForFile = (path: string) => {
|
||||
return `files/${normalizedPath || "unnamed-file"}`
|
||||
}
|
||||
|
||||
const filenameFromPath = (path: string) => {
|
||||
const filename = path
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop()
|
||||
|
||||
if (!filename) return null
|
||||
|
||||
try {
|
||||
return decodeURIComponent(filename)
|
||||
} catch {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
|
||||
const jsonBuffer = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")
|
||||
|
||||
export const buildTenantFullExport = async (
|
||||
@@ -328,11 +345,17 @@ export const buildTenantFullExport = async (
|
||||
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
|
||||
}
|
||||
|
||||
const fileRows = tables.files || []
|
||||
const files = []
|
||||
|
||||
for (const file of fileRows) {
|
||||
if (!file.path) continue
|
||||
const filePaths = new Set<string>()
|
||||
const addExportFile = async (file: {
|
||||
id: string
|
||||
path: string
|
||||
name?: string | null
|
||||
mimeType?: string | null
|
||||
size?: number | null
|
||||
}) => {
|
||||
if (!file.path || filePaths.has(file.path)) return
|
||||
filePaths.add(file.path)
|
||||
|
||||
const object = includeFileContents
|
||||
? await loadObjectAsBase64(file.path)
|
||||
@@ -349,7 +372,7 @@ export const buildTenantFullExport = async (
|
||||
files.push({
|
||||
id: file.id,
|
||||
path: file.path,
|
||||
name: file.name || null,
|
||||
name: file.name || filenameFromPath(file.path),
|
||||
mimeType: file.mimeType || null,
|
||||
size: file.size || null,
|
||||
contentBase64: object.contentBase64,
|
||||
@@ -358,6 +381,30 @@ export const buildTenantFullExport = async (
|
||||
})
|
||||
}
|
||||
|
||||
for (const file of tables.files || []) {
|
||||
if (!file.path) continue
|
||||
|
||||
await addExportFile({
|
||||
id: file.id,
|
||||
path: file.path,
|
||||
name: file.name || null,
|
||||
mimeType: file.mimeType || null,
|
||||
size: file.size || null,
|
||||
})
|
||||
}
|
||||
|
||||
for (const letterhead of tables.letterheads || []) {
|
||||
if (!letterhead.path) continue
|
||||
|
||||
await addExportFile({
|
||||
id: `letterhead:${letterhead.id}`,
|
||||
path: letterhead.path,
|
||||
name: filenameFromPath(letterhead.path),
|
||||
mimeType: "application/pdf",
|
||||
size: null,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
format: "fedeo.tenant-full-export",
|
||||
version: 1,
|
||||
@@ -383,7 +430,7 @@ export const createTenantFullExportArchive = async (
|
||||
.toLowerCase()
|
||||
const filename = options.filename || `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip`
|
||||
const storagePath = options.storagePath || `tenant-exports/${tenantId}/${Date.now()}-${filename}`
|
||||
const fileRows = exportData.tables.files || []
|
||||
const fileRows = exportData.files || []
|
||||
const archive = archiver("zip", { zlib: { level: 6 } })
|
||||
const tempPath = path.join(os.tmpdir(), `fedeo-tenant-export-${tenantId}-${Date.now()}-${filename}`)
|
||||
const output = createWriteStream(tempPath)
|
||||
@@ -522,7 +569,7 @@ const restoreArchiveFiles = async (
|
||||
let skipped = 0
|
||||
const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file]))
|
||||
|
||||
for (const fileRow of exportData.tables.files || []) {
|
||||
for (const fileRow of exportData.files || []) {
|
||||
const originalPath = fileRow.path
|
||||
if (!originalPath) {
|
||||
skipped += 1
|
||||
@@ -587,6 +634,10 @@ const remapTenantScopedExport = (
|
||||
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
|
||||
}
|
||||
|
||||
if (table === "letterheads" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) {
|
||||
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
|
||||
}
|
||||
|
||||
return nextRow
|
||||
})
|
||||
}
|
||||
@@ -721,6 +772,37 @@ const insertRows = async (client: any, table: string, rows: Record<string, any>[
|
||||
return inserted
|
||||
}
|
||||
|
||||
const insertAuthTenantUserRows = async (client: any, rows: Record<string, any>[], metadata: TableMetadata) => {
|
||||
if (!rows.length) return 0
|
||||
|
||||
let inserted = 0
|
||||
const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column)))
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.tenant_id || !row.user_id) continue
|
||||
|
||||
const existing = await client.query(
|
||||
`select 1 from ${quoteIdent("auth_tenant_users")} where ${quoteIdent("tenant_id")} = $1 and ${quoteIdent("user_id")} = $2 limit 1`,
|
||||
[row.tenant_id, row.user_id]
|
||||
)
|
||||
if (existing.rows.length) continue
|
||||
|
||||
const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column))
|
||||
if (!rowColumns.length) continue
|
||||
|
||||
const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
|
||||
const values = rowColumns.map((column) => prepareColumnValue(row[column], metadata.jsonColumns.has(column)))
|
||||
|
||||
await client.query(
|
||||
`insert into ${quoteIdent("auth_tenant_users")} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders})`,
|
||||
values
|
||||
)
|
||||
inserted += 1
|
||||
}
|
||||
|
||||
return inserted
|
||||
}
|
||||
|
||||
const insertIdentityRowsWithRemap = async (
|
||||
client: any,
|
||||
table: string,
|
||||
@@ -830,17 +912,46 @@ export const importTenantFullExport = async (
|
||||
...importOrder,
|
||||
...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
|
||||
].filter((table, index, all) => all.indexOf(table) === index)
|
||||
const specialTables = [
|
||||
columnsByTable.has("auth_tenant_users") ? "auth_tenant_users" : null,
|
||||
columnsByTable.has("bankaccounts") ? "bankaccounts" : null,
|
||||
columnsByTable.has("bankstatements") ? "bankstatements" : null,
|
||||
columnsByTable.has("letterheads") ? "letterheads" : null,
|
||||
].filter(Boolean) as string[]
|
||||
const regularTableNames = tableNames.filter((table) => !specialTables.includes(table))
|
||||
const progressTotal = Math.max(1, specialTables.length + regularTableNames.length + 3)
|
||||
let progressDone = 0
|
||||
const reportProgress = async (message: string) => {
|
||||
await options.onProgress?.({
|
||||
done: Math.min(progressDone, progressTotal),
|
||||
total: progressTotal,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
await client.query("begin")
|
||||
await client.query("set local session_replication_role = replica")
|
||||
await reportProgress("Dateien werden vorbereitet")
|
||||
const files = await restoreFiles(exportData)
|
||||
progressDone += 1
|
||||
await reportProgress("Dateien vorbereitet")
|
||||
|
||||
const importedTables: { table: string; rows: number }[] = []
|
||||
const authTenantUsersMetadata = columnsByTable.get("auth_tenant_users")
|
||||
if (authTenantUsersMetadata) {
|
||||
const count = await insertAuthTenantUserRows(client, exportData.tables.auth_tenant_users || [], authTenantUsersMetadata)
|
||||
importedTables.push({ table: "auth_tenant_users", rows: count })
|
||||
progressDone += 1
|
||||
await reportProgress("Tenant-Zuordnungen importiert")
|
||||
}
|
||||
|
||||
const bankaccountMetadata = columnsByTable.get("bankaccounts")
|
||||
if (bankaccountMetadata) {
|
||||
const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata)
|
||||
remapTableColumn(exportData.tables.bankstatements, "account", result.idMap)
|
||||
importedTables.push({ table: "bankaccounts", rows: result.count })
|
||||
progressDone += 1
|
||||
await reportProgress("Bankkonten importiert")
|
||||
}
|
||||
|
||||
const bankstatementMetadata = columnsByTable.get("bankstatements")
|
||||
@@ -849,25 +960,40 @@ export const importTenantFullExport = async (
|
||||
remapTableColumn(exportData.tables.statementallocations, "bs_id", result.idMap)
|
||||
remapTableColumn(exportData.tables.historyitems, "bankstatement", result.idMap)
|
||||
importedTables.push({ table: "bankstatements", rows: result.count })
|
||||
progressDone += 1
|
||||
await reportProgress("Bankauszüge importiert")
|
||||
}
|
||||
|
||||
for (const table of tableNames) {
|
||||
if (["bankaccounts", "bankstatements"].includes(table)) continue
|
||||
const letterheadMetadata = columnsByTable.get("letterheads")
|
||||
if (letterheadMetadata) {
|
||||
const result = await insertIdentityRowsWithRemap(client, "letterheads", exportData.tables.letterheads || [], letterheadMetadata)
|
||||
remapTableColumn(exportData.tables.createddocuments, "letterhead", result.idMap)
|
||||
importedTables.push({ table: "letterheads", rows: result.count })
|
||||
progressDone += 1
|
||||
await reportProgress("Briefpapiere importiert")
|
||||
}
|
||||
|
||||
for (const table of regularTableNames) {
|
||||
const rows = exportData.tables[table] || []
|
||||
const metadata = columnsByTable.get(table)
|
||||
if (!metadata) continue
|
||||
|
||||
const count = await insertRows(client, table, rows, metadata)
|
||||
importedTables.push({ table, rows: count })
|
||||
progressDone += 1
|
||||
await reportProgress(`${table} importiert`)
|
||||
}
|
||||
|
||||
const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData)
|
||||
if (cleanedCommunicationRooms) {
|
||||
importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms })
|
||||
}
|
||||
progressDone += 1
|
||||
await reportProgress("Kommunikationsräume bereinigt")
|
||||
|
||||
await refreshSequences(client, columnsByTable)
|
||||
progressDone = progressTotal
|
||||
await reportProgress("Import abgeschlossen")
|
||||
await client.query("commit")
|
||||
|
||||
return {
|
||||
|
||||
68
backend/tests/einvoice.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { PDFDocument } from "pdf-lib"
|
||||
import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice"
|
||||
import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice"
|
||||
import { mapElectronicInvoiceToIncomingInvoice } from "../src/modules/einvoice/mapElectronicInvoice"
|
||||
|
||||
const ciiInvoice = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
||||
<rsm:ExchangedDocumentContext><ram:GuidelineSpecifiedDocumentContextParameter><ram:ID>urn:factur-x.eu:1p0:en16931</ram:ID></ram:GuidelineSpecifiedDocumentContextParameter></rsm:ExchangedDocumentContext>
|
||||
<rsm:ExchangedDocument><ram:ID>RE-2026-100</ram:ID><ram:TypeCode>380</ram:TypeCode><ram:IssueDateTime><udt:DateTimeString format="102">20260721</udt:DateTimeString></ram:IssueDateTime></rsm:ExchangedDocument>
|
||||
<rsm:SupplyChainTradeTransaction>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:SpecifiedTradeProduct><ram:Name>Wartung</ram:Name></ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="HUR">2</ram:BilledQuantity></ram:SpecifiedLineTradeDelivery>
|
||||
<ram:SpecifiedLineTradeSettlement><ram:ApplicableTradeTax><ram:CategoryCode>S</ram:CategoryCode><ram:RateApplicablePercent>19</ram:RateApplicablePercent></ram:ApplicableTradeTax><ram:SpecifiedTradeSettlementLineMonetarySummation><ram:LineTotalAmount>100.00</ram:LineTotalAmount></ram:SpecifiedTradeSettlementLineMonetarySummation></ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
<ram:ApplicableHeaderTradeAgreement><ram:BuyerReference>Leitweg-1</ram:BuyerReference><ram:SellerTradeParty><ram:Name>Beispiel GmbH</ram:Name><ram:SpecifiedTaxRegistration><ram:ID schemeID="VA">DE123456789</ram:ID></ram:SpecifiedTaxRegistration></ram:SellerTradeParty></ram:ApplicableHeaderTradeAgreement>
|
||||
<ram:ApplicableHeaderTradeSettlement><ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode><ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>58</ram:TypeCode></ram:SpecifiedTradeSettlementPaymentMeans><ram:SpecifiedTradePaymentTerms><ram:DueDateDateTime><udt:DateTimeString format="102">20260820</udt:DateTimeString></ram:DueDateDateTime></ram:SpecifiedTradePaymentTerms><ram:SpecifiedTradeSettlementHeaderMonetarySummation><ram:LineTotalAmount>100.00</ram:LineTotalAmount><ram:TaxTotalAmount>19.00</ram:TaxTotalAmount><ram:GrandTotalAmount>119.00</ram:GrandTotalAmount><ram:DuePayableAmount>119.00</ram:DuePayableAmount></ram:SpecifiedTradeSettlementHeaderMonetarySummation></ram:ApplicableHeaderTradeSettlement>
|
||||
</rsm:SupplyChainTradeTransaction>
|
||||
</rsm:CrossIndustryInvoice>`)
|
||||
|
||||
const ublInvoice = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ubl:Invoice xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
||||
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0</cbc:CustomizationID><cbc:ID>XR-42</cbc:ID><cbc:IssueDate>2026-07-21</cbc:IssueDate><cbc:DueDate>2026-08-20</cbc:DueDate><cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
||||
<cac:AccountingSupplierParty><cac:Party><cac:PartyName><cbc:Name>UBL Lieferant</cbc:Name></cac:PartyName><cac:PartyTaxScheme><cbc:CompanyID>DE987654321</cbc:CompanyID><cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme></cac:PartyTaxScheme></cac:Party></cac:AccountingSupplierParty>
|
||||
<cac:PaymentMeans><cbc:PaymentMeansCode>30</cbc:PaymentMeansCode></cac:PaymentMeans>
|
||||
<cac:TaxTotal><cbc:TaxAmount currencyID="EUR">7.00</cbc:TaxAmount></cac:TaxTotal>
|
||||
<cac:LegalMonetaryTotal><cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount><cbc:TaxInclusiveAmount currencyID="EUR">107.00</cbc:TaxInclusiveAmount><cbc:PayableAmount currencyID="EUR">107.00</cbc:PayableAmount></cac:LegalMonetaryTotal>
|
||||
<cac:InvoiceLine><cbc:ID>1</cbc:ID><cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity><cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount><cac:Item><cbc:Name>Material</cbc:Name><cac:ClassifiedTaxCategory><cbc:ID>S</cbc:ID><cbc:Percent>7</cbc:Percent></cac:ClassifiedTaxCategory></cac:Item></cac:InvoiceLine>
|
||||
</ubl:Invoice>`)
|
||||
|
||||
test("erkennt und liest eine eingebettete Factur-X-Rechnung", async () => {
|
||||
const document = await PDFDocument.create()
|
||||
document.addPage()
|
||||
await document.attach(ciiInvoice, "factur-x.xml", { mimeType: "application/xml", afRelationship: "Data" })
|
||||
|
||||
const detection = await detectElectronicInvoice(Buffer.from(await document.save()), { name: "rechnung.pdf" })
|
||||
assert.equal(detection?.container, "pdf")
|
||||
assert.equal(detection?.syntax, "cii")
|
||||
|
||||
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
|
||||
assert.equal(parsed.invoiceNumber, "RE-2026-100")
|
||||
assert.equal(parsed.seller.vatId, "DE123456789")
|
||||
assert.equal(parsed.items[0].grossAmount, 119)
|
||||
assert.deepEqual(parsed.validation.errors, [])
|
||||
})
|
||||
|
||||
test("liest eine eigenständige UBL-XRechnung und mappt sie nach FEDEO", async () => {
|
||||
const detection = await detectElectronicInvoice(ublInvoice, { name: "xrechnung.xml" })
|
||||
assert.equal(detection?.syntax, "ubl-invoice")
|
||||
|
||||
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
|
||||
const mapped = mapElectronicInvoiceToIncomingInvoice(parsed, 5, 12, "xml", "xrechnung.xml")
|
||||
|
||||
assert.equal(mapped.reference, "XR-42")
|
||||
assert.equal(mapped.vendor, 12)
|
||||
assert.equal(mapped.paymentType, "Überweisung")
|
||||
assert.equal(mapped.state, "Vorbereitet")
|
||||
assert.equal(mapped.accounts[0].amountTax, 7)
|
||||
})
|
||||
|
||||
test("markiert Summenabweichungen als Prüfungsfehler", () => {
|
||||
const invalid = Buffer.from(ciiInvoice.toString("utf8").replace("<ram:LineTotalAmount>100.00</ram:LineTotalAmount><ram:TaxTotalAmount>", "<ram:LineTotalAmount>90.00</ram:LineTotalAmount><ram:TaxTotalAmount>"))
|
||||
const parsed = parseElectronicInvoice(invalid, "cii")
|
||||
|
||||
assert.match(parsed.validation.errors.join(" "), /Positionssumme/)
|
||||
})
|
||||
27
backend/tests/pdfCharacterError.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { getUnsupportedPdfCharacterMessage } from "../src/utils/pdfCharacterError"
|
||||
|
||||
test("reports unsupported characters with the document position and character offset", () => {
|
||||
// pdf-lib displays supplementary Unicode characters as a private-use glyph,
|
||||
// while the hexadecimal value still contains the original code point.
|
||||
const error = new Error('WinAnsi cannot encode "" (0x1f600)')
|
||||
const documentData = {
|
||||
rows: [
|
||||
{ pos: "1", text: "Montage" },
|
||||
{ pos: "2.3", descriptionText: "Gerät 😀 montieren" },
|
||||
],
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
getUnsupportedPdfCharacterMessage(error, documentData),
|
||||
"Das Zeichen „😀“ (Unicode U+1F600) wird in PDF-Dokumenten nicht unterstützt. Fundstelle: Position 2.3, Beschreibung, Zeichen 7. Bitte ersetze oder entferne das Zeichen."
|
||||
)
|
||||
})
|
||||
|
||||
test("returns null for unrelated PDF errors", () => {
|
||||
assert.equal(
|
||||
getUnsupportedPdfCharacterMessage(new Error("Background PDF is invalid"), {}),
|
||||
null
|
||||
)
|
||||
})
|
||||
@@ -11,6 +11,7 @@ export type AdminTenant = {
|
||||
short: string
|
||||
user_count: number
|
||||
locked?: string | null
|
||||
lockedByExportJobId?: string | null
|
||||
}
|
||||
|
||||
export type AdminUserProfile = {
|
||||
@@ -53,11 +54,20 @@ export type TenantImportResult = {
|
||||
tenantId: number
|
||||
tables: { table: string; rows: number }[]
|
||||
files: { restored: number; skipped: number }
|
||||
importId?: string
|
||||
exportId?: string
|
||||
status?: string
|
||||
filename?: string
|
||||
filesDone?: number
|
||||
filesTotal?: number
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export type TenantExportJob = {
|
||||
exportId: string
|
||||
importId?: string
|
||||
tenantId?: number
|
||||
operation?: "export" | "import" | string
|
||||
status: "queued" | "running" | "ready" | "failed" | string
|
||||
filename: string
|
||||
fileSize?: number | null
|
||||
|
||||
@@ -18,6 +18,12 @@ const tenantExportProgress = ref<null | {
|
||||
filesTotal: number
|
||||
filename: string
|
||||
}>(null)
|
||||
const tenantImportProgress = ref<null | {
|
||||
status: string
|
||||
filesDone: number
|
||||
filesTotal: number
|
||||
filename: string
|
||||
}>(null)
|
||||
const creatingUser = ref(false)
|
||||
const createUserModalOpen = ref(false)
|
||||
const createdUserPassword = ref("")
|
||||
@@ -29,6 +35,13 @@ const lastImportResult = ref<null | {
|
||||
restoredFiles: number
|
||||
skippedFiles: number
|
||||
}>(null)
|
||||
const lockedOptions = [
|
||||
{ label: "Aktiv", value: null },
|
||||
{ label: "Tenant-Wartung", value: "maintenance_tenant" },
|
||||
{ label: "Globale Wartung", value: "maintenance" },
|
||||
{ label: "Allgemeine Sperre", value: "general" },
|
||||
{ label: "Kein Abonnement", value: "no_subscription" },
|
||||
]
|
||||
|
||||
const tenantForm = ref<AdminTenant | null>(null)
|
||||
const assignedUsers = ref<AdminUser[]>([])
|
||||
@@ -77,6 +90,7 @@ const saveTenant = async () => {
|
||||
await admin.updateTenant(tenantForm.value.id, {
|
||||
name: tenantForm.value.name,
|
||||
short: tenantForm.value.short,
|
||||
locked: tenantForm.value.locked || null,
|
||||
})
|
||||
|
||||
await fetchTenant()
|
||||
@@ -161,6 +175,7 @@ const importTenantExport = async (event: Event) => {
|
||||
if (!file || importingTenant.value) return
|
||||
|
||||
importingTenant.value = true
|
||||
tenantImportProgress.value = null
|
||||
lastImportResult.value = null
|
||||
|
||||
try {
|
||||
@@ -180,6 +195,44 @@ const importTenantExport = async (event: Event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const importJobId = result.importId || result.exportId
|
||||
if (importJobId) {
|
||||
let job = result
|
||||
|
||||
tenantImportProgress.value = {
|
||||
status: job.status,
|
||||
filesDone: job.filesDone || 0,
|
||||
filesTotal: job.filesTotal || 0,
|
||||
filename: job.filename || file.name,
|
||||
}
|
||||
|
||||
while (!["ready", "failed"].includes(job.status)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
job = await admin.getTenantExport(importJobId)
|
||||
tenantImportProgress.value = {
|
||||
status: job.status,
|
||||
filesDone: job.filesDone || 0,
|
||||
filesTotal: job.filesTotal || 0,
|
||||
filename: job.filename || file.name,
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status === "failed") {
|
||||
throw new Error(job.error || "Import konnte nicht abgeschlossen werden.")
|
||||
}
|
||||
|
||||
await fetchTenant()
|
||||
await auth.fetchMe()
|
||||
await auth.switchTenant(String(job.tenantId || targetTenantId))
|
||||
|
||||
toast.add({
|
||||
title: "Mandantenimport abgeschlossen",
|
||||
description: job.filename || file.name,
|
||||
color: "green",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
|
||||
|
||||
lastImportResult.value = {
|
||||
@@ -316,7 +369,25 @@ onMounted(async () => {
|
||||
<UFormField label="Kürzel">
|
||||
<UInput v-model="tenantForm.short" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Locked Mode">
|
||||
<USelectMenu
|
||||
v-model="tenantForm.locked"
|
||||
:items="lockedOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
/>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
|
||||
<UAlert
|
||||
v-if="tenantForm.lockedByExportJobId"
|
||||
class="mt-4"
|
||||
title="Automatische Sperre aktiv"
|
||||
:description="`Dieser Tenant wird gerade durch Export/Import ${tenantForm.lockedByExportJobId} pausiert.`"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="!loading && tenantForm" class="mt-3">
|
||||
@@ -370,6 +441,17 @@ onMounted(async () => {
|
||||
>
|
||||
Export importieren
|
||||
</UButton>
|
||||
<div v-if="tenantImportProgress" class="mt-4 space-y-2">
|
||||
<UProgress
|
||||
:model-value="tenantImportProgress.filesTotal ? Math.round((tenantImportProgress.filesDone / tenantImportProgress.filesTotal) * 100) : undefined"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ tenantImportProgress.status === 'ready' ? 'Import abgeschlossen' : 'Import wird verarbeitet' }}
|
||||
<span v-if="tenantImportProgress.filesTotal">
|
||||
· {{ tenantImportProgress.filesDone }} / {{ tenantImportProgress.filesTotal }} Schritte
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -91,10 +91,12 @@ const selectedProductcategorie = ref(null)
|
||||
const services = ref([])
|
||||
const servicecategories = ref([])
|
||||
const selectedServicecategorie = ref(null)
|
||||
const collapsedTitleRowIds = ref(new Set())
|
||||
const customers = ref([])
|
||||
const contacts = ref([])
|
||||
const contracts = ref([])
|
||||
const outgoingsepamandates = ref([])
|
||||
const bankaccounts = ref([])
|
||||
const texttemplates = ref([])
|
||||
const units = ref([])
|
||||
const tenantUsers = ref([])
|
||||
@@ -108,6 +110,11 @@ const taxTypeItems = [
|
||||
{ key: '19 UStG', label: '19 UStG Kleinunternehmer' },
|
||||
{ key: '12.3 UStG', label: '12.3 UStG' }
|
||||
]
|
||||
const titleLevelItems = [
|
||||
{ label: 'Titel Ebene 1', value: 1 },
|
||||
{ label: 'Untertitel Ebene 2', value: 2 },
|
||||
{ label: 'Untertitel Ebene 3', value: 3 }
|
||||
]
|
||||
const deliveryDateTypeItems = ['Lieferdatum', 'Lieferzeitraum', 'Leistungsdatum', 'Leistungszeitraum', 'Kein Lieferdatum anzeigen']
|
||||
const paymentTypeItems = [
|
||||
{ key: 'transfer', label: 'Überweisung' },
|
||||
@@ -231,6 +238,12 @@ const setupData = async () => {
|
||||
contacts.value = await useEntities("contacts").select("*")
|
||||
contracts.value = await useEntities("contracts").select("*")
|
||||
outgoingsepamandates.value = await useEntities("outgoingsepamandates").select("*")
|
||||
try {
|
||||
bankaccounts.value = await useEntities("bankaccounts").select("*")
|
||||
} catch (error) {
|
||||
console.warn("Bankkonten konnten nicht für den Zahlungs-QR-Code geladen werden", error)
|
||||
bankaccounts.value = []
|
||||
}
|
||||
texttemplates.value = await useEntities("texttemplates").select("*")
|
||||
units.value = await useEntities("units").selectSpecial("*")
|
||||
tenantUsers.value = (await useNuxtApp().$api(`/api/tenant/users`, {
|
||||
@@ -351,6 +364,10 @@ const normalizeCreatedDocumentRow = (row) => {
|
||||
const normalizeCreatedDocumentRows = (rows) => Array.isArray(rows)
|
||||
? rows.map((row) => normalizeCreatedDocumentRow(row))
|
||||
: []
|
||||
const isImportedAdvanceInvoiceRow = (row) => {
|
||||
return Array.isArray(row.linkedEntitys)
|
||||
&& row.linkedEntitys.some(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")
|
||||
}
|
||||
const setupPage = async () => {
|
||||
|
||||
await setupData()
|
||||
@@ -507,6 +524,8 @@ const setupPage = async () => {
|
||||
})
|
||||
|
||||
for await (const doc of linkedDocuments.filter(i => i.type === "advanceInvoices")) {
|
||||
const advanceInvoiceNet = useSum().getCreatedDocumentSumDetailed(doc).totalNet * -1
|
||||
|
||||
itemInfo.value.rows.push({
|
||||
mode: "free",
|
||||
text: `Abschlagsrechnung ${doc.documentNumber}`,
|
||||
@@ -514,7 +533,8 @@ const setupPage = async () => {
|
||||
taxPercent: 19, // TODO TAX PERCENTAGE
|
||||
discountPercent: 0,
|
||||
unit: 10,
|
||||
inputPrice: useSum().getCreatedDocumentSumDetailed(doc).totalNet * -1,
|
||||
inputPrice: advanceInvoiceNet,
|
||||
price: advanceInvoiceNet,
|
||||
linkedEntitys: [
|
||||
{
|
||||
type: "createddocuments",
|
||||
@@ -819,21 +839,19 @@ const getRowAmountUndiscounted = (row) => {
|
||||
return String(Number(Number(row.quantity) * Number(row.price)).toFixed(2)).replace('.', ',')
|
||||
}
|
||||
|
||||
const addPosition = (mode) => {
|
||||
|
||||
let lastId = 0
|
||||
itemInfo.value.rows.forEach(row => {
|
||||
if (row.id > lastId) lastId = row.id
|
||||
})
|
||||
|
||||
let taxPercentage = 19
|
||||
|
||||
if (['13b UStG', '19 UStG', '12.3 UStG'].includes(itemInfo.value.taxType)) {
|
||||
taxPercentage = 0
|
||||
}
|
||||
const positionAddOptions = [
|
||||
{ mode: 'service', label: 'Leistung', icon: 'i-heroicons-wrench-screwdriver' },
|
||||
{ mode: 'normal', label: 'Artikel', icon: 'i-heroicons-cube' },
|
||||
{ mode: 'free', label: 'Freie Position', icon: 'i-heroicons-pencil-square' },
|
||||
{ mode: 'pagebreak', label: 'Seitenumbruch', icon: 'i-heroicons-document-minus' },
|
||||
{ mode: 'title', label: 'Titel', icon: 'i-heroicons-bars-3-bottom-left' },
|
||||
{ mode: 'subtitle', label: 'Untertitel', icon: 'i-heroicons-list-bullet' },
|
||||
{ mode: 'text', label: 'Text', icon: 'i-heroicons-document-text' }
|
||||
]
|
||||
|
||||
const createPositionRow = (mode, taxPercentage) => {
|
||||
if (mode === 'free') {
|
||||
let rowData = {
|
||||
const rowData = {
|
||||
id: uuidv4(),
|
||||
mode: "free",
|
||||
text: "",
|
||||
@@ -848,10 +866,11 @@ const addPosition = (mode) => {
|
||||
linkedEntitys: []
|
||||
}
|
||||
|
||||
itemInfo.value.rows.push({...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}})
|
||||
return {...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}}
|
||||
}
|
||||
|
||||
} else if (mode === 'normal') {
|
||||
itemInfo.value.rows.push({
|
||||
if (mode === 'normal') {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
mode: "normal",
|
||||
quantity: 1,
|
||||
@@ -862,9 +881,11 @@ const addPosition = (mode) => {
|
||||
unit: 1,
|
||||
costCentre: itemInfo.value.costcentre,
|
||||
linkedEntitys: []
|
||||
})
|
||||
} else if (mode === 'service') {
|
||||
let rowData = {
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'service') {
|
||||
const rowData = {
|
||||
id: uuidv4(),
|
||||
mode: "service",
|
||||
quantity: 1,
|
||||
@@ -877,26 +898,103 @@ const addPosition = (mode) => {
|
||||
linkedEntitys: []
|
||||
}
|
||||
|
||||
//Push Agriculture Holder only if Module is activated
|
||||
itemInfo.value.rows.push({...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}})
|
||||
} else if (mode === "pagebreak") {
|
||||
itemInfo.value.rows.push({
|
||||
return {...rowData, ...auth.activeTenantData.extraModules.includes("agriculture") ? {agriculture: {}} : {}}
|
||||
}
|
||||
|
||||
if (mode === "pagebreak") {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
mode: "pagebreak",
|
||||
linkedEntitys: []
|
||||
})
|
||||
} else if (mode === "title") {
|
||||
itemInfo.value.rows.push({
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "title" || mode === "subtitle") {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
mode: "title",
|
||||
titleLevel: mode === "subtitle" ? 2 : 1,
|
||||
linkedEntitys: []
|
||||
})
|
||||
} else if (mode === "text") {
|
||||
itemInfo.value.rows.push({
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "text") {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
mode: "text",
|
||||
linkedEntitys: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getPositionAddMenuItems = (row) => [
|
||||
positionAddOptions.map(option => ({
|
||||
...option,
|
||||
onSelect: () => addPosition(option.mode, row?.id)
|
||||
}))
|
||||
]
|
||||
|
||||
const toggleTitleSection = (rowId) => {
|
||||
const nextCollapsedTitleRowIds = new Set(collapsedTitleRowIds.value)
|
||||
|
||||
if (nextCollapsedTitleRowIds.has(rowId)) {
|
||||
nextCollapsedTitleRowIds.delete(rowId)
|
||||
} else {
|
||||
nextCollapsedTitleRowIds.add(rowId)
|
||||
}
|
||||
|
||||
collapsedTitleRowIds.value = nextCollapsedTitleRowIds
|
||||
}
|
||||
|
||||
const hiddenDocumentRowIds = computed(() => {
|
||||
const hiddenRowIds = new Set()
|
||||
const collapsedTitleLevels = []
|
||||
|
||||
itemInfo.value.rows.forEach((row) => {
|
||||
if (row.mode === 'title') {
|
||||
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
|
||||
|
||||
while (
|
||||
collapsedTitleLevels.length > 0
|
||||
&& collapsedTitleLevels[collapsedTitleLevels.length - 1] >= titleLevel
|
||||
) {
|
||||
collapsedTitleLevels.pop()
|
||||
}
|
||||
|
||||
if (collapsedTitleLevels.length > 0) hiddenRowIds.add(row.id)
|
||||
if (collapsedTitleRowIds.value.has(row.id)) collapsedTitleLevels.push(titleLevel)
|
||||
} else if (collapsedTitleLevels.length > 0) {
|
||||
hiddenRowIds.add(row.id)
|
||||
}
|
||||
})
|
||||
|
||||
return hiddenRowIds
|
||||
})
|
||||
|
||||
const isDocumentRowHidden = (rowId) => hiddenDocumentRowIds.value.has(rowId)
|
||||
|
||||
const getFullWidthPositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 5 : 8
|
||||
|
||||
const getTitlePositionColspan = () => deliveryNoteLikeDocumentTypes.includes(itemInfo.value.type) ? 4 : 7
|
||||
|
||||
const addPosition = (mode, afterRowId = null) => {
|
||||
let taxPercentage = 19
|
||||
|
||||
if (['13b UStG', '19 UStG', '12.3 UStG'].includes(itemInfo.value.taxType)) {
|
||||
taxPercentage = 0
|
||||
}
|
||||
|
||||
const newRow = createPositionRow(mode, taxPercentage)
|
||||
if (!newRow) return
|
||||
|
||||
const insertAfterIndex = afterRowId ? itemInfo.value.rows.findIndex(row => row.id === afterRowId) : -1
|
||||
|
||||
if (insertAfterIndex >= 0) {
|
||||
itemInfo.value.rows.splice(insertAfterIndex + 1, 0, newRow)
|
||||
} else {
|
||||
itemInfo.value.rows.push(newRow)
|
||||
}
|
||||
|
||||
setPosNumbers()
|
||||
@@ -1101,22 +1199,27 @@ const documentTotal = computed(() => {
|
||||
|
||||
let titleSums = {}
|
||||
let titleSumsTransfer = {}
|
||||
let lastTitle = ""
|
||||
let activeTitleKeys = []
|
||||
let transferCounter = 0
|
||||
|
||||
itemInfo.value.rows.forEach(row => {
|
||||
if (row.mode === 'title') {
|
||||
let title = `${row.pos} - ${row.text}`
|
||||
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
|
||||
titleSums[title] = 0
|
||||
lastTitle = title
|
||||
if (activeTitleKeys.some(Boolean)) {
|
||||
titleSumsTransfer[title] = transferCounter
|
||||
}
|
||||
activeTitleKeys = activeTitleKeys.slice(0, titleLevel - 1)
|
||||
activeTitleKeys[titleLevel - 1] = title
|
||||
activeTitleKeys = activeTitleKeys.slice(0, titleLevel)
|
||||
} else if (!['pagebreak', 'text'].includes(row.mode) && activeTitleKeys.some(Boolean) && !row.optional && !row.alternative) {
|
||||
const rowSum = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100))
|
||||
|
||||
//Übertrag berechnen
|
||||
titleSumsTransfer[Object.keys(titleSums)[row.pos - 2]] = transferCounter
|
||||
|
||||
|
||||
} else if (!['pagebreak', 'text'].includes(row.mode) && lastTitle !== "" && !row.optional && !row.alternative) {
|
||||
titleSums[lastTitle] = Number(titleSums[lastTitle]) + Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100))
|
||||
transferCounter += Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100))
|
||||
activeTitleKeys.forEach(titleKey => {
|
||||
titleSums[titleKey] = Number(titleSums[titleKey]) + rowSum
|
||||
})
|
||||
transferCounter += rowSum
|
||||
console.log(transferCounter)
|
||||
}
|
||||
})
|
||||
@@ -1308,7 +1411,11 @@ const getDocumentData = async () => {
|
||||
|
||||
let customerData = customers.value.find(i => i.id === itemInfo.value.customer)
|
||||
let contactData = contacts.value.find(i => i.id === itemInfo.value.contact)
|
||||
let businessInfo = auth.activeTenantData.businessInfo
|
||||
let businessInfo = auth.activeTenantData.businessInfo || {}
|
||||
const paymentQrBankAccount = bankaccounts.value.find(account => !account.archived && !account.expired && account.iban)
|
||||
const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban
|
||||
const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic
|
||||
const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || auth.activeTenantData.name
|
||||
|
||||
if (auth.activeTenantData.extraModules.includes("agriculture")) {
|
||||
itemInfo.value.rows.forEach(row => {
|
||||
@@ -1353,11 +1460,11 @@ const getDocumentData = async () => {
|
||||
|
||||
return {
|
||||
...row,
|
||||
rowAmount: `${getRowAmount(row)} €`,
|
||||
rowAmount: renderCurrency(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)),
|
||||
quantity: String(row.quantity).replace(".", ","),
|
||||
unit: unit.short,
|
||||
pos: String(row.pos),
|
||||
price: `${String(row.price.toFixed(2)).replace(".", ",")} €`,
|
||||
price: renderCurrency(row.price),
|
||||
discountText: `(Rabatt: ${row.discountPercent} %)`
|
||||
}
|
||||
} else {
|
||||
@@ -1524,7 +1631,14 @@ const getDocumentData = async () => {
|
||||
agriculture: itemInfo.value.agriculture,
|
||||
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices.map(i => {
|
||||
return createddocuments.value.find(x => x.id === i)
|
||||
})
|
||||
}),
|
||||
paymentQr: paymentQrIban && itemInfo.value.payment_type === "transfer" ? {
|
||||
iban: paymentQrIban,
|
||||
bic: paymentQrBic,
|
||||
recipientName: paymentQrRecipient,
|
||||
amount: documentTotal.value.totalSumToPay,
|
||||
remittance: itemInfo.value.documentNumber || itemInfo.value.title,
|
||||
} : null
|
||||
}
|
||||
|
||||
console.log(returnData)
|
||||
@@ -1566,16 +1680,33 @@ const onChangeTab = (index) => {
|
||||
}
|
||||
|
||||
const setPosNumbers = () => {
|
||||
let mainIndex = 1
|
||||
let subIndex = 1
|
||||
let titleCounters = [0, 0, 0]
|
||||
let positionIndex = 1
|
||||
let currentTitlePath = []
|
||||
const hasTitles = itemInfo.value.rows.some(row => row.mode === "title")
|
||||
|
||||
let rows = itemInfo.value.rows.map(row => {
|
||||
if (row.mode === 'title') {
|
||||
row.pos = mainIndex
|
||||
mainIndex += 1
|
||||
subIndex = 1
|
||||
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), titleCounters.length)
|
||||
row.titleLevel = titleLevel
|
||||
|
||||
for (let index = 0; index < titleLevel - 1; index += 1) {
|
||||
if (titleCounters[index] === 0) {
|
||||
titleCounters[index] = 1
|
||||
}
|
||||
}
|
||||
|
||||
titleCounters[titleLevel - 1] += 1
|
||||
for (let index = titleLevel; index < titleCounters.length; index += 1) {
|
||||
titleCounters[index] = 0
|
||||
}
|
||||
|
||||
currentTitlePath = titleCounters.slice(0, titleLevel)
|
||||
row.pos = currentTitlePath.join(".")
|
||||
positionIndex = 1
|
||||
} else if (!['pagebreak', 'title', 'text'].includes(row.mode)) {
|
||||
row.pos = itemInfo.value.rows.filter(i => i.mode === "title").length === 0 ? `${subIndex}` : `${mainIndex - 1}.${subIndex}`
|
||||
subIndex += 1
|
||||
row.pos = hasTitles && currentTitlePath.length > 0 ? `${currentTitlePath.join(".")}.${positionIndex}` : `${positionIndex}`
|
||||
positionIndex += 1
|
||||
}
|
||||
|
||||
if (!row.id) {
|
||||
@@ -1628,6 +1759,7 @@ const saveSerialInvoice = async () => {
|
||||
const saveDocument = async (state, resetup = false) => {
|
||||
|
||||
itemInfo.value.state = state
|
||||
normalizeCustomSurchargePercentage()
|
||||
|
||||
if (state !== "Entwurf") {
|
||||
console.log("???")
|
||||
@@ -1797,17 +1929,24 @@ const checkCompatibilityWithInputPrice = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeCustomSurchargePercentage = () => {
|
||||
const percentage = Number(itemInfo.value.customSurchargePercentage)
|
||||
itemInfo.value.customSurchargePercentage = Number.isFinite(percentage) ? percentage : 0
|
||||
}
|
||||
|
||||
const updateCustomSurcharge = () => {
|
||||
normalizeCustomSurchargePercentage()
|
||||
|
||||
itemInfo.value.rows.forEach(row => {
|
||||
if (!["pagebreak", "title", "text"].includes(row.mode) /*&& !row.linkedEntitys.find(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")*/) {
|
||||
if (!["pagebreak", "title", "text"].includes(row.mode) && isImportedAdvanceInvoiceRow(row)) {
|
||||
row.price = Number(row.inputPrice || 0)
|
||||
} else if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
||||
//setRowData(row)
|
||||
|
||||
row.price = Number((row.inputPrice * (1 + itemInfo.value.customSurchargePercentage / 100)).toFixed(2))
|
||||
|
||||
|
||||
}/* else if(!["pagebreak","title","text"].includes(row.mode) /!*&& row.linkedEntitys.find(i => i.type === "createddocuments" && i.subtype === "advanceInvoices")*!/) {
|
||||
row.price = row.inputPrice
|
||||
}*/
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2790,6 +2929,8 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Rabatt</th>-->
|
||||
<th class="pl-2"></th>
|
||||
<th class="pl-2" v-if="!deliveryNoteLikeDocumentTypes.includes(itemInfo.type)">Gesamt</th>
|
||||
<th class="pl-2"></th>
|
||||
<th class="pl-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<draggable
|
||||
@@ -2800,23 +2941,38 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
@end="setPosNumbers"
|
||||
>
|
||||
<template #item="{element: row}">
|
||||
<tr>
|
||||
<tr :class="{ collapse: isDocumentRowHidden(row.id) }">
|
||||
<td>
|
||||
<UIcon
|
||||
class="handle"
|
||||
name="i-mdi-menu"
|
||||
v-if="itemInfo.type !== 'cancellationInvoices'"
|
||||
/>
|
||||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||
<UIcon
|
||||
class="handle shrink-0"
|
||||
name="i-mdi-menu"
|
||||
v-if="itemInfo.type !== 'cancellationInvoices'"
|
||||
/>
|
||||
<UTooltip
|
||||
v-if="row.mode === 'title'"
|
||||
:text="collapsedTitleRowIds.has(row.id) ? 'Titelbereich ausklappen' : 'Titelbereich einklappen'"
|
||||
>
|
||||
<UButton
|
||||
class="shrink-0"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:icon="collapsedTitleRowIds.has(row.id) ? 'i-heroicons-chevron-right' : 'i-heroicons-chevron-down'"
|
||||
:aria-label="collapsedTitleRowIds.has(row.id) ? 'Titelbereich ausklappen' : 'Titelbereich einklappen'"
|
||||
@click="toggleTitleSection(row.id)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
v-if="row.mode === 'pagebreak'"
|
||||
colspan="8"
|
||||
:colspan="getFullWidthPositionColspan()"
|
||||
>
|
||||
<USeparator/>
|
||||
</td>
|
||||
<td
|
||||
v-if="row.mode === 'text'"
|
||||
colspan="8"
|
||||
:colspan="getFullWidthPositionColspan()"
|
||||
>
|
||||
<!-- <UInput
|
||||
v-model="row.text"
|
||||
@@ -3289,7 +3445,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
</InputGroup>
|
||||
|
||||
<UAlert
|
||||
v-if="row.linkedEntitys && row.linkedEntitys.find(i => i.type === 'createddocuments' && i.subtype === 'advanceInvoices')"
|
||||
v-if="isImportedAdvanceInvoiceRow(row)"
|
||||
title="Berechnung des Individuellen Aufschlags für die Zeile gesperrt"
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
@@ -3423,14 +3579,37 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
</td>
|
||||
<td
|
||||
v-if="row.mode === 'title'"
|
||||
colspan="7"
|
||||
:colspan="getTitlePositionColspan()"
|
||||
>
|
||||
<UInput
|
||||
:disabled="itemInfo.type === 'cancellationInvoices'"
|
||||
v-model="row.text"
|
||||
placeholder="Titel"
|
||||
class="w-full"
|
||||
/>
|
||||
<InputGroup class="w-full">
|
||||
<USelect
|
||||
:disabled="itemInfo.type === 'cancellationInvoices'"
|
||||
v-model="row.titleLevel"
|
||||
:items="titleLevelItems"
|
||||
value-key="value"
|
||||
class="w-44 shrink-0"
|
||||
@update:model-value="setPosNumbers"
|
||||
/>
|
||||
<UInput
|
||||
:disabled="itemInfo.type === 'cancellationInvoices'"
|
||||
v-model="row.text"
|
||||
placeholder="Titel"
|
||||
class="w-full min-w-0"
|
||||
/>
|
||||
</InputGroup>
|
||||
</td>
|
||||
<td>
|
||||
<UDropdownMenu
|
||||
:items="getPositionAddMenuItems(row)"
|
||||
:content="{ align: 'end' }"
|
||||
>
|
||||
<UButton
|
||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
icon="i-heroicons-plus"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</td>
|
||||
<td>
|
||||
<UButton
|
||||
@@ -3455,46 +3634,13 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
|
||||
<InputGroup>
|
||||
<UButton
|
||||
@click="addPosition('service')"
|
||||
v-for="option in positionAddOptions"
|
||||
:key="option.mode"
|
||||
@click="addPosition(option.mode)"
|
||||
class="mt-3"
|
||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||
>
|
||||
+ Leistung
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="addPosition('normal')"
|
||||
class="mt-3"
|
||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||
>
|
||||
+ Artikel
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="addPosition('free')"
|
||||
class="mt-3"
|
||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||
>
|
||||
+ Freie Position
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="addPosition('pagebreak')"
|
||||
class="mt-3"
|
||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||
>
|
||||
+ Seitenumbruch
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="addPosition('title')"
|
||||
class="mt-3"
|
||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||
>
|
||||
+ Titel
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="addPosition('text')"
|
||||
class="mt-3"
|
||||
:disabled="['advanceInvoices','cancellationInvoices'].includes(itemInfo.type)"
|
||||
>
|
||||
+ Text
|
||||
+ {{ option.label }}
|
||||
</UButton>
|
||||
</InputGroup>
|
||||
|
||||
|
||||
@@ -149,6 +149,18 @@ const bankBookingDateLabel = computed(() => {
|
||||
return bankBookingDates.value.map(formatDate).join(", ")
|
||||
})
|
||||
const vendorName = computed(() => vendors.value.find((vendor) => vendor.id === itemInfo.value.vendor)?.name || "-")
|
||||
const eInvoiceValidation = computed(() => itemInfo.value.eInvoiceValidation || null)
|
||||
const eInvoiceSourceLabel = computed(() => {
|
||||
if (itemInfo.value.preparationSource !== "e-invoice") return null
|
||||
if (itemInfo.value.eInvoiceSyntax === "cii") {
|
||||
return String(itemInfo.value.eInvoiceProfile || "").toLowerCase().includes("xrechnung")
|
||||
? "XRechnung (CII)"
|
||||
: "ZUGFeRD / Factur-X (CII)"
|
||||
}
|
||||
if (itemInfo.value.eInvoiceSyntax === "ubl-credit-note") return "XRechnung / UBL-Gutschrift"
|
||||
return "XRechnung / UBL"
|
||||
})
|
||||
const eInvoiceProfileLabel = computed(() => itemInfo.value.eInvoiceProfile || "Profil nicht angegeben")
|
||||
const getAccountLabel = (item) => {
|
||||
const account = accounts.value.find((entry) => entry.id === item.account)
|
||||
|
||||
@@ -356,6 +368,35 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
||||
Dokument andocken
|
||||
</UButton>
|
||||
|
||||
<UAlert
|
||||
v-if="eInvoiceSourceLabel"
|
||||
:title="eInvoiceSourceLabel"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-document-check"
|
||||
>
|
||||
<template #description>
|
||||
<div class="mt-1 space-y-2 text-sm">
|
||||
<p class="break-all">{{ eInvoiceProfileLabel }}</p>
|
||||
<div v-if="eInvoiceValidation?.errors?.length">
|
||||
<p class="font-semibold">Validierungsfehler</p>
|
||||
<ul class="list-inside list-disc">
|
||||
<li v-for="error in eInvoiceValidation.errors" :key="error">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="eInvoiceValidation?.warnings?.length">
|
||||
<p class="font-semibold">Hinweise</p>
|
||||
<ul class="list-inside list-disc">
|
||||
<li v-for="warning in eInvoiceValidation.warnings" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-if="!eInvoiceValidation?.errors?.length && !eInvoiceValidation?.warnings?.length">
|
||||
Pflichtfelder und Rechnungssummen wurden erfolgreich geprüft.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
v-if="mode !== 'show' && findIncomingInvoiceErrors.length > 0"
|
||||
title="Prüfung erforderlich"
|
||||
|
||||
@@ -287,6 +287,18 @@ const selectIncomingInvoice = (invoiceLike) => {
|
||||
<span v-if="row.original.state === 'Vorbereitet'" class="text-cyan-500">{{row.original.state}}</span>
|
||||
<span v-else-if="row.original.state === 'Entwurf'" class="text-red-500">{{row.original.state}}</span>
|
||||
<span v-else-if="row.original.state === 'Gebucht'" class="text-primary-500">{{row.original.state}}</span>
|
||||
<span v-else-if="row.original.state === 'Prüfung erforderlich'" class="text-orange-500">{{row.original.state}}</span>
|
||||
<span v-else>{{row.original.state}}</span>
|
||||
</template>
|
||||
<template #preparationSource-cell="{row}">
|
||||
<UBadge v-if="row.original.preparationSource === 'e-invoice'" color="primary" variant="soft">
|
||||
{{ row.original.eInvoiceSyntax === 'cii'
|
||||
? (String(row.original.eInvoiceProfile || '').toLowerCase().includes('xrechnung') ? 'XRechnung / CII' : 'ZUGFeRD / CII')
|
||||
: 'XRechnung / UBL' }}
|
||||
</UBadge>
|
||||
<UBadge v-else-if="row.original.preparationSource === 'gpt'" color="neutral" variant="soft">
|
||||
PDF / KI
|
||||
</UBadge>
|
||||
</template>
|
||||
<template #date-cell="{row}">
|
||||
{{dayjs(row.original.date).format("DD.MM.YYYY")}}
|
||||
|
||||
@@ -11,7 +11,8 @@ const router = useRouter()
|
||||
|
||||
const state = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
password: '',
|
||||
rememberMe: true
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -19,7 +20,7 @@ const loading = ref(false)
|
||||
const doLogin = async (event: FormSubmitEvent<typeof state>) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(event.data.email, event.data.password)
|
||||
await auth.login(event.data.email, event.data.password, event.data.rememberMe)
|
||||
toast.add({title:"Einloggen erfolgreich"})
|
||||
await router.push("/")
|
||||
} catch (err: any) {
|
||||
@@ -79,6 +80,12 @@ const doLogin = async (event: FormSubmitEvent<typeof state>) => {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UCheckbox
|
||||
v-model="state.rememberMe"
|
||||
name="rememberMe"
|
||||
label="Angemeldet bleiben"
|
||||
/>
|
||||
|
||||
<UButton type="submit" block class="w-full" :loading="loading">
|
||||
Weiter
|
||||
</UButton>
|
||||
|
||||
@@ -214,17 +214,34 @@ const activeTelephonyTargetOptions = computed(() => {
|
||||
if (telephonyExtensionForm.targetType === "branch") return telephonyExtensionOptions.value.branches || []
|
||||
return telephonyExtensionOptions.value.users || []
|
||||
})
|
||||
const defaultBusinessInfo = {
|
||||
name: "",
|
||||
street: "",
|
||||
zip: "",
|
||||
city: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
website: "",
|
||||
taxNumber: "",
|
||||
vatId: "",
|
||||
bankName: "",
|
||||
bankAccountOwner: "",
|
||||
iban: "",
|
||||
bic: "",
|
||||
}
|
||||
const normalizeBusinessInfo = (value) => ({ ...defaultBusinessInfo, ...(value || {}) })
|
||||
|
||||
const setupPage = async () => {
|
||||
itemInfo.value = auth.activeTenantData
|
||||
console.log(itemInfo.value)
|
||||
businessInfo.value = normalizeBusinessInfo(auth.activeTenantData?.businessInfo)
|
||||
planningBoardConfig.startTime = auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00"
|
||||
planningBoardConfig.endTime = auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00"
|
||||
planningBoardConfig.slotMinutes = auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
|
||||
}
|
||||
|
||||
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
|
||||
const businessInfo = ref(auth.activeTenantData.businessInfo)
|
||||
const businessInfo = ref(normalizeBusinessInfo(auth.activeTenantData?.businessInfo))
|
||||
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
|
||||
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
|
||||
const accountChartOptions = [
|
||||
@@ -245,6 +262,7 @@ const updateTenant = async (newData) => {
|
||||
itemInfo.value = res
|
||||
auth.activeTenantData = res
|
||||
features.value = { ...defaultFeatures, ...(res?.features || {}) }
|
||||
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
|
||||
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
|
||||
}
|
||||
}
|
||||
@@ -628,6 +646,51 @@ onMounted(() => {
|
||||
<UInput v-model="businessInfo.city" class="flex-auto"/>
|
||||
</InputGroup>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="E-Mail:"
|
||||
>
|
||||
<UInput v-model="businessInfo.email"/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Telefon:"
|
||||
>
|
||||
<UInput v-model="businessInfo.phone"/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Website:"
|
||||
>
|
||||
<UInput v-model="businessInfo.website"/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Steuernummer:"
|
||||
>
|
||||
<UInput v-model="businessInfo.taxNumber"/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="USt-IdNr.:"
|
||||
>
|
||||
<UInput v-model="businessInfo.vatId"/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Bank:"
|
||||
>
|
||||
<UInput v-model="businessInfo.bankName"/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Kontoinhaber:"
|
||||
>
|
||||
<UInput v-model="businessInfo.bankAccountOwner"/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="IBAN:"
|
||||
>
|
||||
<UInput v-model="businessInfo.iban"/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="BIC:"
|
||||
>
|
||||
<UInput v-model="businessInfo.bic"/>
|
||||
</UFormField>
|
||||
<UButton
|
||||
class="mt-3"
|
||||
@click="updateTenant({businessInfo: businessInfo})"
|
||||
|
||||
@@ -45,6 +45,11 @@ export default defineNuxtPlugin(() => {
|
||||
let title = "Fehler"
|
||||
let description = "Ein unerwarteter Fehler ist aufgetreten."
|
||||
|
||||
if (status === 422 && response._data?.code === "UNSUPPORTED_PDF_CHARACTER") {
|
||||
title = "Nicht unterstütztes Zeichen"
|
||||
description = response._data.error
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
title = "Anfrage fehlerhaft"
|
||||
|
||||
@@ -27,6 +27,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
sessionWarningTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
sessionLogoutTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
sessionCountdownTimer: null as ReturnType<typeof setInterval> | null,
|
||||
sessionPersistent: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
@@ -36,18 +37,18 @@ export const useAuthStore = defineStore("auth", {
|
||||
|
||||
getStoredToken() {
|
||||
const rootToken = this.tokenCookie().value
|
||||
if (rootToken || !process.client) return rootToken
|
||||
if (!process.client) return rootToken
|
||||
|
||||
const tokenCookie = document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith("token="))
|
||||
return sessionStorage.getItem("token")
|
||||
|| localStorage.getItem("token")
|
||||
|| rootToken
|
||||
},
|
||||
|
||||
if (tokenCookie) {
|
||||
return decodeURIComponent(tokenCookie.slice("token=".length))
|
||||
}
|
||||
getStoredRefreshToken() {
|
||||
if (!process.client) return null
|
||||
|
||||
return localStorage.getItem("token")
|
||||
return sessionStorage.getItem("refreshToken")
|
||||
|| localStorage.getItem("refreshToken")
|
||||
},
|
||||
|
||||
clearScopedTokenCookies() {
|
||||
@@ -155,24 +156,34 @@ export const useAuthStore = defineStore("auth", {
|
||||
const msUntilWarning = msUntilLogout - this.sessionWarningLeadMs
|
||||
|
||||
if (msUntilWarning <= 0) {
|
||||
if (this.getStoredRefreshToken()) {
|
||||
void this.refreshSession()
|
||||
return
|
||||
}
|
||||
this.openSessionWarning(expiresAtMs)
|
||||
return
|
||||
}
|
||||
|
||||
this.sessionWarningTimer = setTimeout(() => {
|
||||
if (this.getStoredRefreshToken()) {
|
||||
void this.refreshSession()
|
||||
return
|
||||
}
|
||||
this.openSessionWarning(expiresAtMs)
|
||||
}, msUntilWarning)
|
||||
},
|
||||
|
||||
setToken(token: string | null) {
|
||||
setToken(token: string | null, persistent = this.sessionPersistent) {
|
||||
this.clearScopedTokenCookies()
|
||||
this.tokenCookie().value = token
|
||||
|
||||
if (process.client) {
|
||||
localStorage.removeItem("token")
|
||||
sessionStorage.removeItem("token")
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem("token", token)
|
||||
} else {
|
||||
localStorage.removeItem("token")
|
||||
const storage = persistent ? localStorage : sessionStorage
|
||||
storage.setItem("token", token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +197,30 @@ export const useAuthStore = defineStore("auth", {
|
||||
this.scheduleSessionTimers(token)
|
||||
},
|
||||
|
||||
setRefreshToken(refreshToken: string | null, persistent = this.sessionPersistent) {
|
||||
if (!process.client) return
|
||||
|
||||
localStorage.removeItem("refreshToken")
|
||||
sessionStorage.removeItem("refreshToken")
|
||||
|
||||
if (refreshToken) {
|
||||
const storage = persistent ? localStorage : sessionStorage
|
||||
storage.setItem("refreshToken", refreshToken)
|
||||
}
|
||||
},
|
||||
|
||||
setSession(token: string, refreshToken: string, persistent: boolean) {
|
||||
this.sessionPersistent = persistent
|
||||
this.setRefreshToken(refreshToken, persistent)
|
||||
this.setToken(token, persistent)
|
||||
},
|
||||
|
||||
clearStoredSession() {
|
||||
this.setRefreshToken(null)
|
||||
this.setToken(null)
|
||||
this.sessionPersistent = false
|
||||
},
|
||||
|
||||
async persist(token: string | null) {
|
||||
|
||||
console.log("On Web")
|
||||
@@ -195,6 +230,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
|
||||
async initStore() {
|
||||
console.log("Auth initStore")
|
||||
this.sessionPersistent = process.client && Boolean(localStorage.getItem("refreshToken"))
|
||||
|
||||
// 1. Check: Haben wir überhaupt ein Token?
|
||||
const token = this.getStoredToken()
|
||||
@@ -207,7 +243,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
}
|
||||
|
||||
// 2. Token existiert -> Versuche User zu laden
|
||||
await this.fetchMe(token)
|
||||
await this.fetchMe(token, true)
|
||||
|
||||
// Wenn fetchMe fertig ist (egal ob Erfolg oder Fehler), ladebalken weg
|
||||
|
||||
@@ -236,18 +272,18 @@ export const useAuthStore = defineStore("auth", {
|
||||
}
|
||||
},
|
||||
|
||||
async login(email: string, password: string) {
|
||||
async login(email: string, password: string, rememberMe = false) {
|
||||
try {
|
||||
console.log("Auth login")
|
||||
const { token } = await useNuxtApp().$api("/auth/login", {
|
||||
const { token, refreshToken } = await useNuxtApp().$api("/auth/login", {
|
||||
method: "POST",
|
||||
body: { email, password }
|
||||
body: { email, password, rememberMe }
|
||||
})
|
||||
|
||||
console.log("Token: " + token)
|
||||
|
||||
// 1. WICHTIG: Token sofort ins Cookie schreiben, damit es persistiert wird
|
||||
this.setToken(token)
|
||||
// Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern
|
||||
this.setSession(token, refreshToken, rememberMe)
|
||||
this.sessionExpired = false
|
||||
|
||||
// 2. User Daten laden
|
||||
@@ -269,14 +305,18 @@ export const useAuthStore = defineStore("auth", {
|
||||
|
||||
} catch (e) {
|
||||
console.log("login error:" + e)
|
||||
// Hier könnte man noch eine Fehlermeldung im UI anzeigen
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
console.log("Auth logout")
|
||||
const refreshToken = this.getStoredRefreshToken()
|
||||
try {
|
||||
await useNuxtApp().$api("/auth/logout", { method: "POST" })
|
||||
await useNuxtApp().$api("/auth/logout", {
|
||||
method: "POST",
|
||||
body: { refreshToken }
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Logout API fehlgeschlagen (egal):", e)
|
||||
}
|
||||
@@ -285,7 +325,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
this.resetState()
|
||||
|
||||
// Token löschen
|
||||
this.setToken(null)
|
||||
this.clearStoredSession()
|
||||
|
||||
// Nur beim expliziten Logout navigieren wir
|
||||
navigateTo("/login")
|
||||
@@ -295,7 +335,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
console.log("Auth session expired")
|
||||
this.resetState()
|
||||
this.sessionExpired = true
|
||||
this.setToken(null)
|
||||
this.clearStoredSession()
|
||||
this.loading = false
|
||||
|
||||
if (process.client) {
|
||||
@@ -317,7 +357,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async fetchMe(jwt= null) {
|
||||
async fetchMe(jwt= null, allowRefresh = true) {
|
||||
console.log("Auth fetchMe")
|
||||
const tempStore = useTempStore()
|
||||
|
||||
@@ -384,6 +424,10 @@ export const useAuthStore = defineStore("auth", {
|
||||
console.log("fetchMe failed (Invalid Token or Network)", err)
|
||||
|
||||
if (err?.response?.status === 401 || err?.status === 401 || err?.statusCode === 401) {
|
||||
if (allowRefresh && this.getStoredRefreshToken()) {
|
||||
await this.refreshSession()
|
||||
return
|
||||
}
|
||||
this.expireSession()
|
||||
return
|
||||
}
|
||||
@@ -395,17 +439,31 @@ export const useAuthStore = defineStore("auth", {
|
||||
},
|
||||
|
||||
async refreshSession() {
|
||||
const refreshToken = this.getStoredRefreshToken()
|
||||
if (!refreshToken) {
|
||||
this.expireSession()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { token } = await useNuxtApp().$api("/api/auth/refresh", {
|
||||
const session = await useNuxtApp().$api("/auth/refresh", {
|
||||
method: "POST",
|
||||
body: { refreshToken }
|
||||
})
|
||||
|
||||
this.setToken(token)
|
||||
await this.fetchMe(token)
|
||||
this.setSession(session.token, session.refreshToken, this.sessionPersistent)
|
||||
await this.fetchMe(session.token, false)
|
||||
this.sessionWarningVisible = false
|
||||
} catch (err) {
|
||||
console.error("JWT refresh failed", err)
|
||||
await this.logout()
|
||||
const status = err?.response?.status || err?.status || err?.statusCode
|
||||
if (status === 401) {
|
||||
this.expireSession()
|
||||
} else {
|
||||
const token = this.getStoredToken()
|
||||
const expiresAtMs = token ? this.decodeTokenExpiryMs(token) : null
|
||||
if (expiresAtMs) this.openSessionWarning(expiresAtMs)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -414,14 +472,15 @@ export const useAuthStore = defineStore("auth", {
|
||||
this.loading = true
|
||||
const res = await useNuxtApp().$api("/api/tenant/switch", {
|
||||
method: "POST",
|
||||
body: { tenant_id }
|
||||
body: {
|
||||
tenant_id,
|
||||
refreshToken: this.getStoredRefreshToken()
|
||||
}
|
||||
})
|
||||
console.log(res)
|
||||
|
||||
const {token} = res
|
||||
|
||||
|
||||
this.setToken(token)
|
||||
const {token, refreshToken} = res
|
||||
this.setSession(token, refreshToken, this.sessionPersistent)
|
||||
|
||||
|
||||
await this.init(token)
|
||||
|
||||
@@ -2460,6 +2460,9 @@ export const useDataStore = defineStore('data', () => {
|
||||
key: 'reference',
|
||||
label: "Referenz:",
|
||||
sortable: true,
|
||||
}, {
|
||||
key: 'preparationSource',
|
||||
label: "Quelle",
|
||||
}, {
|
||||
key: 'state',
|
||||
label: "Status:"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "software.federspiel.fedeo",
|
||||
"buildNumber": "5",
|
||||
"buildNumber": "7",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
|
||||
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
|
||||
@@ -28,9 +28,8 @@
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
],
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#69c350",
|
||||
"backgroundColor": "#ffffff",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
@@ -50,7 +49,8 @@
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#0f1f0d"
|
||||
"image": "./assets/images/splash-icon-dark.png",
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
export default function IndexScreen() {
|
||||
const { isBootstrapping, token, requiresTenantSelection } = useAuth();
|
||||
const { isBootstrapping, bootstrapError, token, requiresTenantSelection, retryBootstrap, resetLocalSession } = useAuth();
|
||||
|
||||
if (isBootstrapping) {
|
||||
return (
|
||||
@@ -15,6 +15,23 @@ export default function IndexScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (bootstrapError) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.title}>Session konnte nicht gestartet werden</Text>
|
||||
<Text style={styles.copy}>{bootstrapError}</Text>
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.primaryButton} onPress={retryBootstrap}>
|
||||
<Text style={styles.primaryButtonText}>Erneut versuchen</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.secondaryButton} onPress={resetLocalSession}>
|
||||
<Text style={styles.secondaryButtonText}>Lokale Session löschen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
@@ -37,5 +54,43 @@ const styles = StyleSheet.create({
|
||||
copy: {
|
||||
fontSize: 14,
|
||||
color: '#4b5563',
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
width: '100%',
|
||||
gap: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
primaryButton: {
|
||||
minHeight: 44,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#69c350',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryButton: {
|
||||
minHeight: 44,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
backgroundColor: '#ffffff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: '#374151',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function LoginScreen() {
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
|
||||
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
|
||||
const [showServerModal, setShowServerModal] = useState(false);
|
||||
@@ -141,14 +142,23 @@ export default function LoginScreen() {
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Passwort</Text>
|
||||
<TextInput
|
||||
secureTextEntry
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<View style={styles.passwordRow}>
|
||||
<TextInput
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.passwordInput}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isPasswordVisible ? 'Passwort ausblenden' : 'Passwort einblenden'}
|
||||
style={styles.passwordToggle}
|
||||
onPress={() => setIsPasswordVisible((visible) => !visible)}>
|
||||
<Text style={styles.passwordToggleText}>{isPasswordVisible ? 'Ausblenden' : 'Anzeigen'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
@@ -251,6 +261,30 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
color: '#111827',
|
||||
},
|
||||
passwordRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
},
|
||||
passwordInput: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
color: '#111827',
|
||||
},
|
||||
passwordToggle: {
|
||||
alignSelf: 'stretch',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
passwordToggleText: {
|
||||
color: PRIMARY,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
button: {
|
||||
marginTop: 6,
|
||||
backgroundColor: PRIMARY,
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 52 KiB |
BIN
mobile/assets/images/splash-icon-dark.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 39 KiB |
980
mobile/package-lock.json
generated
@@ -25,17 +25,18 @@
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.34",
|
||||
"expo": "~54.0.35",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "^14.0.8",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-file-system": "~19.0.23",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-router": "~6.0.24",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
|
||||
@@ -131,6 +131,11 @@ export type MeResponse = {
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export type EncodedLabelRow = {
|
||||
dataType: 'pixels' | 'void' | 'check';
|
||||
rowNumber: number;
|
||||
@@ -196,8 +201,23 @@ type RequestOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
token?: string | null;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthenticationError(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
export type MatrixStatus = {
|
||||
enabled?: boolean;
|
||||
ready?: boolean;
|
||||
@@ -314,17 +334,46 @@ async function parseJson(response: Response): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
function createTimeoutSignal(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): { signal: AbortSignal; cleanup: () => void } {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => clearTimeout(timeout),
|
||||
};
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const hasJsonBody = options.body !== undefined;
|
||||
const response = await fetch(buildUrl(path), {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
},
|
||||
body: hasJsonBody ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
const { signal, cleanup } = createTimeoutSignal(options.timeoutMs);
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(buildUrl(path), {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
},
|
||||
body: hasJsonBody ? JSON.stringify(options.body) : undefined,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw new Error(`Zeitüberschreitung beim Verbinden mit dem FEDEO-Server (${path}).`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const payload = await parseJson(response);
|
||||
|
||||
@@ -333,21 +382,34 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
(payload as { message?: string; error?: string } | null)?.message ||
|
||||
(payload as { message?: string; error?: string } | null)?.error ||
|
||||
`Request failed (${response.status}) for ${path}`;
|
||||
throw new Error(message);
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
|
||||
const response = await fetch(buildUrl(path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
const { signal, cleanup } = createTimeoutSignal();
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(buildUrl(path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw new Error(`Zeitüberschreitung beim Hochladen zum FEDEO-Server (${path}).`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const payload = await parseJson(response);
|
||||
|
||||
@@ -356,7 +418,7 @@ async function apiFormRequest<T>(path: string, token: string, formData: FormData
|
||||
(payload as { message?: string; error?: string } | null)?.message ||
|
||||
(payload as { message?: string; error?: string } | null)?.error ||
|
||||
`Request failed (${response.status}) for ${path}`;
|
||||
throw new Error(message);
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
@@ -586,17 +648,37 @@ export async function renderPrintLabel(
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginWithEmailPassword(email: string, password: string): Promise<string> {
|
||||
const payload = await apiRequest<{ token?: string }>('/auth/login', {
|
||||
function requireAuthSession(payload: Partial<AuthSession>, action: string): AuthSession {
|
||||
if (!payload?.token || !payload?.refreshToken) {
|
||||
throw new Error(`${action} did not return a complete session.`);
|
||||
}
|
||||
|
||||
return { token: payload.token, refreshToken: payload.refreshToken };
|
||||
}
|
||||
|
||||
export async function loginWithEmailPassword(email: string, password: string): Promise<AuthSession> {
|
||||
const payload = await apiRequest<Partial<AuthSession>>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
});
|
||||
|
||||
if (!payload?.token) {
|
||||
throw new Error('Login did not return a token.');
|
||||
}
|
||||
return requireAuthSession(payload, 'Login');
|
||||
}
|
||||
|
||||
return payload.token;
|
||||
export async function refreshAuthSession(refreshToken: string): Promise<AuthSession> {
|
||||
const payload = await apiRequest<Partial<AuthSession>>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: { refreshToken },
|
||||
});
|
||||
|
||||
return requireAuthSession(payload, 'Session refresh');
|
||||
}
|
||||
|
||||
export async function logoutSession(refreshToken: string): Promise<void> {
|
||||
await apiRequest('/auth/logout', {
|
||||
method: 'POST',
|
||||
body: { refreshToken },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMe(token: string): Promise<MeResponse> {
|
||||
@@ -623,18 +705,18 @@ export async function sendMobileTestPush(token: string): Promise<{ accepted: num
|
||||
});
|
||||
}
|
||||
|
||||
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> {
|
||||
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
|
||||
export async function switchTenantRequest(
|
||||
tenantId: number,
|
||||
token: string,
|
||||
refreshToken: string
|
||||
): Promise<AuthSession> {
|
||||
const payload = await apiRequest<Partial<AuthSession>>('/api/tenant/switch', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { tenant_id: String(tenantId) },
|
||||
body: { tenant_id: String(tenantId), refreshToken },
|
||||
});
|
||||
|
||||
if (!payload?.token) {
|
||||
throw new Error('Tenant switch did not return a token.');
|
||||
}
|
||||
|
||||
return payload.token;
|
||||
return requireAuthSession(payload, 'Tenant switch');
|
||||
}
|
||||
|
||||
export async function fetchTasks(token: string): Promise<Task[]> {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const TOKEN_KEY = 'fedeo.mobile.auth.token';
|
||||
const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token';
|
||||
|
||||
let memoryToken: string | null = null;
|
||||
let memoryRefreshToken: string | null = null;
|
||||
|
||||
export type StoredSession = {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
async function hasSecureStore(): Promise<boolean> {
|
||||
try {
|
||||
@@ -30,11 +37,41 @@ export async function setStoredToken(token: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStoredToken(): Promise<void> {
|
||||
memoryToken = null;
|
||||
export async function getStoredSession(): Promise<StoredSession | null> {
|
||||
if (await hasSecureStore()) {
|
||||
const [token, refreshToken] = await Promise.all([
|
||||
SecureStore.getItemAsync(TOKEN_KEY),
|
||||
SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
|
||||
]);
|
||||
memoryToken = token;
|
||||
memoryRefreshToken = refreshToken;
|
||||
}
|
||||
|
||||
if (!memoryToken || !memoryRefreshToken) return null;
|
||||
return { token: memoryToken, refreshToken: memoryRefreshToken };
|
||||
}
|
||||
|
||||
export async function setStoredSession(session: StoredSession): Promise<void> {
|
||||
memoryToken = session.token;
|
||||
memoryRefreshToken = session.refreshToken;
|
||||
|
||||
if (await hasSecureStore()) {
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||
await Promise.all([
|
||||
SecureStore.setItemAsync(TOKEN_KEY, session.token),
|
||||
SecureStore.setItemAsync(REFRESH_TOKEN_KEY, session.refreshToken),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStoredToken(): Promise<void> {
|
||||
memoryToken = null;
|
||||
memoryRefreshToken = null;
|
||||
|
||||
if (await hasSecureStore()) {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(TOKEN_KEY),
|
||||
SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
|
||||
import {
|
||||
fetchMe,
|
||||
loginWithEmailPassword,
|
||||
logoutSession,
|
||||
MeResponse,
|
||||
isAuthenticationError,
|
||||
refreshAuthSession,
|
||||
switchTenantRequest,
|
||||
Tenant,
|
||||
} from '@/src/lib/api';
|
||||
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
|
||||
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
|
||||
import {
|
||||
clearStoredToken,
|
||||
getStoredSession,
|
||||
setStoredSession,
|
||||
tokenStorageInfo,
|
||||
} from '@/src/lib/token-storage';
|
||||
|
||||
export type AuthUser = MeResponse['user'];
|
||||
|
||||
type AuthContextValue = {
|
||||
isBootstrapping: boolean;
|
||||
bootstrapError: string | null;
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
tenants: Tenant[];
|
||||
@@ -20,9 +35,13 @@ type AuthContextValue = {
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
switchTenant: (tenantId: number) => Promise<void>;
|
||||
retryBootstrap: () => Promise<void>;
|
||||
resetLocalSession: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
const BOOTSTRAP_TIMEOUT_MS = 20000;
|
||||
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 60 * 1000;
|
||||
|
||||
function normalizeTenantId(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
@@ -33,14 +52,28 @@ function normalizeTenantId(value: number | string | null | undefined): number |
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
|
||||
promise
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => clearTimeout(timeout));
|
||||
});
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
|
||||
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
const bootstrapRunRef = useRef(0);
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
setUser(null);
|
||||
@@ -63,10 +96,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (refreshToken) {
|
||||
void logoutSession(refreshToken).catch(() => undefined);
|
||||
}
|
||||
await clearStoredToken();
|
||||
setToken(null);
|
||||
setRefreshToken(null);
|
||||
setBootstrapError(null);
|
||||
resetSession();
|
||||
}, [resetSession]);
|
||||
}, [refreshToken, resetSession]);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
if (!token) {
|
||||
@@ -81,55 +119,116 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [hydrateSession, logout, resetSession, token]);
|
||||
|
||||
useEffect(() => {
|
||||
async function bootstrap() {
|
||||
await hydrateApiBaseUrl();
|
||||
const storedToken = await getStoredToken();
|
||||
const resetLocalSession = useCallback(async () => {
|
||||
setToken(null);
|
||||
setRefreshToken(null);
|
||||
setBootstrapError(null);
|
||||
setIsBootstrapping(false);
|
||||
resetSession();
|
||||
void clearStoredToken().catch(() => undefined);
|
||||
}, [resetSession]);
|
||||
|
||||
if (!storedToken) {
|
||||
setIsBootstrapping(false);
|
||||
return;
|
||||
}
|
||||
const bootstrap = useCallback(async () => {
|
||||
const runId = bootstrapRunRef.current + 1;
|
||||
bootstrapRunRef.current = runId;
|
||||
setIsBootstrapping(true);
|
||||
setBootstrapError(null);
|
||||
|
||||
try {
|
||||
await hydrateSession(storedToken);
|
||||
setToken(storedToken);
|
||||
} catch {
|
||||
await clearStoredToken();
|
||||
try {
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
await hydrateApiBaseUrl();
|
||||
const storedSession = await getStoredSession();
|
||||
|
||||
if (!storedSession) {
|
||||
if (bootstrapRunRef.current !== runId) return;
|
||||
setToken(null);
|
||||
resetSession();
|
||||
return;
|
||||
}
|
||||
|
||||
let activeSession = storedSession;
|
||||
try {
|
||||
await hydrateSession(activeSession.token);
|
||||
} catch {
|
||||
activeSession = await refreshAuthSession(activeSession.refreshToken);
|
||||
await setStoredSession(activeSession);
|
||||
await hydrateSession(activeSession.token);
|
||||
}
|
||||
|
||||
if (bootstrapRunRef.current !== runId) return;
|
||||
setToken(activeSession.token);
|
||||
setRefreshToken(activeSession.refreshToken);
|
||||
})(),
|
||||
BOOTSTRAP_TIMEOUT_MS,
|
||||
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
|
||||
);
|
||||
} catch (err) {
|
||||
if (bootstrapRunRef.current === runId) {
|
||||
setToken(null);
|
||||
setRefreshToken(null);
|
||||
resetSession();
|
||||
} finally {
|
||||
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
|
||||
if (isAuthenticationError(err)) {
|
||||
void clearStoredToken().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (bootstrapRunRef.current === runId) {
|
||||
setIsBootstrapping(false);
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
}, [hydrateSession, resetSession]);
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
const nextToken = await loginWithEmailPassword(email, password);
|
||||
await setStoredToken(nextToken);
|
||||
await hydrateSession(nextToken);
|
||||
setToken(nextToken);
|
||||
const session = await loginWithEmailPassword(email, password);
|
||||
await setStoredSession(session);
|
||||
await hydrateSession(session.token);
|
||||
setToken(session.token);
|
||||
setRefreshToken(session.refreshToken);
|
||||
},
|
||||
[hydrateSession]
|
||||
);
|
||||
|
||||
const switchTenant = useCallback(
|
||||
async (tenantId: number) => {
|
||||
if (!token) {
|
||||
if (!token || !refreshToken) {
|
||||
throw new Error('No active session found.');
|
||||
}
|
||||
|
||||
const nextToken = await switchTenantRequest(tenantId, token);
|
||||
await setStoredToken(nextToken);
|
||||
await hydrateSession(nextToken);
|
||||
setToken(nextToken);
|
||||
const session = await switchTenantRequest(tenantId, token, refreshToken);
|
||||
await setStoredSession(session);
|
||||
await hydrateSession(session.token);
|
||||
setToken(session.token);
|
||||
setRefreshToken(session.refreshToken);
|
||||
},
|
||||
[token, hydrateSession]
|
||||
[token, refreshToken, hydrateSession]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshToken) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const session = await refreshAuthSession(refreshToken);
|
||||
await setStoredSession(session);
|
||||
setToken(session.token);
|
||||
setRefreshToken(session.refreshToken);
|
||||
} catch {
|
||||
// Bei einem temporären Netzfehler bleibt die gespeicherte Session erhalten.
|
||||
}
|
||||
})();
|
||||
}, SESSION_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshToken]);
|
||||
|
||||
const activeTenant = useMemo(
|
||||
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
|
||||
[activeTenantId, tenants]
|
||||
@@ -140,6 +239,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isBootstrapping,
|
||||
bootstrapError,
|
||||
token,
|
||||
user,
|
||||
tenants,
|
||||
@@ -152,10 +252,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
logout,
|
||||
refreshUser,
|
||||
switchTenant,
|
||||
retryBootstrap: bootstrap,
|
||||
resetLocalSession,
|
||||
}),
|
||||
[
|
||||
activeTenant,
|
||||
activeTenantId,
|
||||
bootstrap,
|
||||
bootstrapError,
|
||||
isBootstrapping,
|
||||
login,
|
||||
logout,
|
||||
@@ -163,6 +267,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
profile,
|
||||
refreshUser,
|
||||
requiresTenantSelection,
|
||||
resetLocalSession,
|
||||
switchTenant,
|
||||
tenants,
|
||||
token,
|
||||
|
||||
@@ -30,6 +30,14 @@ read_interactive() {
|
||||
fi
|
||||
}
|
||||
|
||||
docker_engine() {
|
||||
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
||||
sudo docker "$@"
|
||||
else
|
||||
docker "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
||||
sudo docker compose "$@"
|
||||
@@ -44,12 +52,27 @@ compose_stack() {
|
||||
|
||||
compose_start_command() {
|
||||
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
||||
echo "sudo docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d"
|
||||
echo "sudo docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d --remove-orphans"
|
||||
else
|
||||
echo "docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d"
|
||||
echo "docker compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d --remove-orphans"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_selfhost_networks() {
|
||||
local network
|
||||
|
||||
for network in fedeo_web fedeo_internal; do
|
||||
if docker_engine network inspect "$network" >/dev/null 2>&1; then
|
||||
if docker_engine network rm "$network" >/dev/null 2>&1; then
|
||||
echo "Docker-Netzwerk entfernt: $network"
|
||||
else
|
||||
echo "Docker-Netzwerk konnte nicht entfernt werden: $network"
|
||||
echo "Prüfe laufende Container mit: docker network inspect $network"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
FEDEO Selfhost Setup
|
||||
@@ -461,6 +484,8 @@ uninstall_stack() {
|
||||
"$ROOT_DIR/traefik/letsencrypt" \
|
||||
"$ROOT_DIR/traefik/logs"
|
||||
|
||||
remove_selfhost_networks
|
||||
|
||||
echo
|
||||
echo "Uninstall inklusive lokaler Daten abgeschlossen."
|
||||
}
|
||||
@@ -597,7 +622,7 @@ main() {
|
||||
fi
|
||||
|
||||
if [[ "$START_STACK" == "yes" ]]; then
|
||||
compose_stack up -d
|
||||
compose_stack up -d --remove-orphans
|
||||
else
|
||||
echo
|
||||
echo "Start später mit:"
|
||||
|
||||