23 Commits

Author SHA1 Message Date
ed305f656c Merge pull request 'dev' (#238) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m14s
Reviewed-on: #238
2026-10-02 14:18:01 +00:00
30e932a874 Merge pull request 'dev' (#237) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #237
2026-10-02 12:51:46 +00:00
0a74e07288 Merge pull request 'fix(scanner): accept Epson empty write probes' (#234) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m8s
Reviewed-on: #234
2026-10-01 15:36:56 +00:00
4c3de8c942 Merge pull request 'dev' (#233) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m11s
Reviewed-on: #233
2026-09-30 17:23:15 +00:00
49d44d9701 Merge pull request 'dev' (#232) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m18s
Reviewed-on: #232
2026-09-30 15:43:45 +00:00
96a7acff33 Merge pull request 'feat(selfhost): show sanitized update logs in admin UI' (#231) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #231
2026-09-25 17:22:30 +00:00
65588686c7 Merge pull request 'fix(documents): normalize units when selecting and loading rows' (#230) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 6m3s
Reviewed-on: #230
2026-09-25 09:54:08 +00:00
fcf9cc3fc9 Merge pull request 'dev' (#229) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 49s
Build and Release FEDEO / build-and-release (push) Successful in 6m6s
Reviewed-on: #229
2026-09-25 09:24:39 +00:00
20091143d2 Merge pull request 'dev' (#228) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 6m5s
Reviewed-on: #228
2026-09-23 17:20:58 +00:00
f708d0a2b2 Merge pull request 'dev' (#227) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 44s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #227
2026-09-23 16:15:44 +00:00
7e9f4409f6 Merge pull request 'dev' (#226) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 33s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
Reviewed-on: #226
2026-09-23 14:57:40 +00:00
2e63af1aa2 Merge pull request 'fix(release): stream large migration checksums from git' (#225) from flfeders/codex/release-migration-checksums into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m59s
Reviewed-on: #225
2026-09-23 14:09:57 +00:00
5ebd2d508c Merge pull request 'fix(release): exclude legacy main history from commit checks' (#224) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 5m57s
Reviewed-on: #224
2026-09-23 13:03:36 +00:00
9128fbb165 Merge pull request 'fix(ci): build dev images without repository write access' (#221) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 26s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #221
2026-09-20 16:25:38 +00:00
8473221c13 Merge pull request '3.0.0' (#220) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 22s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #220
2026-09-20 16:10:46 +00:00
e7554fa2cc .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:42:28 +00:00
7c1fabf58a .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:40:58 +00:00
1203b6cbd1 .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 11:39:03 +00:00
525f2906fb .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2026-01-15 11:38:50 +00:00
b105382abf .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 11:38:00 +00:00
b1cdec7d17 Merge pull request 'Added feature request template' (#62) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #62
2026-01-15 11:31:57 +00:00
f1d512b2e5 Merge pull request 'dev' (#61) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #61
2026-01-15 11:29:15 +00:00
db21b43120 Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #40
2026-01-08 22:21:06 +00:00
212 changed files with 1252 additions and 11658 deletions

View File

@@ -51,13 +51,11 @@ SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
M2M_API_KEY=change-this-m2m-key
API_BASE_URL=https://app.example.com/backend
# Collaboration uses API_BASE_URL for federation and its origin for guest links.
# Optional overrides only, e.g. when using a separate federation gateway.
FEDEO_PUBLIC_URL=
FEDEO_FEDERATION_URL=
# auto: enabled with a valid HTTPS API address and ENCRYPTION_KEY; false: opt out.
# Partners still require mutual tenant approval under Zusammenarbeit.
FEDEO_FEDERATION_ENABLED=auto
# Collaboration: public browser URL and canonical HTTPS API address (including reverse-proxy prefix).
FEDEO_PUBLIC_URL=https://app.example.com
FEDEO_FEDERATION_URL=https://app.example.com/backend
# Requires mutual tenant partner approval under Zusammenarbeit.
FEDEO_FEDERATION_ENABLED=false
GOCARDLESS_BASE_URL=https://api.gocardless.com
GOCARDLESS_SECRET_ID=replace-this
GOCARDLESS_SECRET_KEY=replace-this

View File

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

View File

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

View File

@@ -87,8 +87,6 @@ Minimal wichtige Werte fur den ersten Start:
- `DATABASE_URL=postgres://fedeo:<starkes-passwort>@db:5432/fedeo`
- `API_BASE_URL=https://app.example.com/backend`
Die Chat-Föderation verwendet ebenfalls `API_BASE_URL`; Gastlinks übernehmen deren Ursprung. Mit gültiger öffentlicher HTTPS-Adresse auf Port 443 und vorhandenem gültigem `ENCRYPTION_KEY` ist sie automatisch verfügbar. Zusätzliche URLs sind nur bei abweichender Infrastruktur nötig (`FEDEO_FEDERATION_URL`, `FEDEO_PUBLIC_URL`). `FEDEO_FEDERATION_ENABLED=false` schaltet sie ausdrücklich ab; der Standard ist `auto`. Der Updater übernimmt die passenden Compose-Einträge aus dem Release. Partner müssen anschließend weiterhin je Mandant freigegeben werden; siehe [Föderation einrichten](docs/chat-foederation-konzept.md#15-implementierter-stand-und-pilotbetrieb).
Wenn du MinIO verwendest, setze zusatzlich:
- `S3_ENDPOINT=http://minio:9000`

View File

@@ -1,8 +0,0 @@
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "chat_enabled" boolean NOT NULL DEFAULT true;
--> statement-breakpoint
CREATE TABLE "communication_hidden_rooms" (
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE CASCADE,
"room_key" text NOT NULL,
PRIMARY KEY ("tenant_id", "user_id", "room_key")
);

View File

@@ -1,19 +0,0 @@
INSERT INTO notifications_event_types (event_key, display_name, description, category, allowed_channels) VALUES
('communication.message.direct', 'Direktnachrichten', 'Neue persönliche Chatnachrichten.', 'communication', '["inapp","email","push"]'),
('communication.message.mentioned', 'Erwähnungen', 'Du wirst in einem Chatraum oder Projektchat persönlich erwähnt.', 'communication', '["inapp","email","push"]'),
('communication.message.room', 'Raum- und Projektnachrichten', 'Neue Nachrichten in deinen Chaträumen, ohne persönliche Erwähnung.', 'communication', '["inapp","email","push"]')
ON CONFLICT (event_key) DO NOTHING;
--> statement-breakpoint
UPDATE notifications_event_types SET allowed_channels = '["inapp","email","push"]', display_name = 'Neuer Chatraum', description = 'Du wirst einem neu angelegten Chatraum hinzugefügt.' WHERE event_key = 'communication.room.invited';
--> statement-breakpoint
INSERT INTO notifications_preferences (tenant_id, user_id, event_type, channel, enabled)
SELECT tenant_id, user_id, replacement.key, channel, enabled FROM notifications_preferences
CROSS JOIN (VALUES ('communication.message.direct'), ('communication.message.mentioned')) AS replacement(key)
WHERE event_type = 'communication.message.new' ON CONFLICT DO NOTHING;
--> statement-breakpoint
INSERT INTO notifications_preferences_defaults (tenant_id, event_key, channel, enabled)
SELECT tenant_id, replacement.key, channel, enabled FROM notifications_preferences_defaults
CROSS JOIN (VALUES ('communication.message.direct'), ('communication.message.mentioned')) AS replacement(key)
WHERE event_key = 'communication.message.new' ON CONFLICT DO NOTHING;
--> statement-breakpoint
UPDATE notifications_event_types SET is_active = false WHERE event_key IN ('communication.message.new', 'communication.call.started', 'communication.call.missed');

View File

@@ -1,93 +0,0 @@
CREATE TABLE staff_time_v2_accounts (
tenant_id bigint NOT NULL REFERENCES tenants(id), user_id uuid NOT NULL REFERENCES auth_users(id),
activated_at timestamptz NOT NULL DEFAULT now(), activated_by uuid REFERENCES auth_users(id),
PRIMARY KEY(tenant_id,user_id)
);
--> statement-breakpoint
CREATE TABLE staff_time_spans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id bigint NOT NULL REFERENCES tenants(id),
user_id uuid NOT NULL REFERENCES auth_users(id),
type text NOT NULL CHECK(type IN ('work','vacation','sick','overtime_compensation','holiday','other')),
started_at timestamptz NOT NULL, ended_at timestamptz,
date_from date, date_to date, timezone text NOT NULL DEFAULT 'Europe/Berlin',
description text NOT NULL DEFAULT '', source text NOT NULL,
status text NOT NULL DEFAULT 'factual' CHECK(status IN ('factual','submitted','approved','rejected')),
version integer NOT NULL DEFAULT 1 CHECK(version > 0),
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
CHECK(ended_at IS NULL OR ended_at > started_at),
CHECK((date_from IS NULL AND date_to IS NULL) OR (type <> 'work' AND date_from IS NOT NULL AND date_to IS NOT NULL AND date_to > date_from AND ended_at IS NOT NULL)),
CHECK(status = 'factual' OR ended_at IS NOT NULL)
);
CREATE INDEX staff_time_spans_user_time ON staff_time_spans(tenant_id,user_id,started_at);
CREATE UNIQUE INDEX staff_time_spans_one_open ON staff_time_spans(tenant_id,user_id) WHERE ended_at IS NULL;
--> statement-breakpoint
CREATE TABLE staff_time_span_breaks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
started_at timestamptz NOT NULL, ended_at timestamptz,
CHECK(ended_at IS NULL OR ended_at > started_at)
);
CREATE INDEX staff_time_span_breaks_parent ON staff_time_span_breaks(span_id);
CREATE UNIQUE INDEX staff_time_span_breaks_one_open ON staff_time_span_breaks(span_id) WHERE ended_at IS NULL;
CREATE TABLE staff_time_span_projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
project_id bigint NOT NULL REFERENCES projects(id), started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL,
notes text NOT NULL DEFAULT '', CHECK(ended_at > started_at)
);
CREATE INDEX staff_time_span_projects_parent ON staff_time_span_projects(span_id);
--> statement-breakpoint
CREATE TABLE staff_time_span_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
actor_id uuid REFERENCES auth_users(id), action text NOT NULL, reason text NOT NULL DEFAULT '',
before_data jsonb, after_data jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX staff_time_span_history_parent ON staff_time_span_history(span_id,created_at);
CREATE TABLE staff_time_v2_requests (
tenant_id bigint NOT NULL, user_id uuid NOT NULL, request_id text NOT NULL,
fingerprint text NOT NULL, response jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(tenant_id,user_id,request_id)
);
CREATE TABLE staff_time_span_origins (
tenant_id bigint NOT NULL, source_kind text NOT NULL, source_id uuid NOT NULL,
span_id uuid NOT NULL REFERENCES staff_time_spans(id), PRIMARY KEY(tenant_id,source_kind,source_id)
);
--> statement-breakpoint
-- The same lock as the service makes cutover atomic with concurrent legacy writers.
CREATE FUNCTION staff_time_legacy_write_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE t bigint; u uuid;
BEGIN
IF TG_OP = 'DELETE' THEN t := OLD.tenant_id; u := OLD.user_id;
ELSE t := NEW.tenant_id; u := NEW.user_id; END IF;
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || t || ':' || u,0));
IF EXISTS(SELECT 1 FROM staff_time_v2_accounts WHERE tenant_id=t AND user_id=u) THEN
RAISE EXCEPTION 'Zeiterfassung 2.0 ist aktiv. Bitte die neue Erfassung verwenden.' USING ERRCODE='23514';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER staff_time_events_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_events FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_write_guard();
CREATE TRIGGER staff_time_entries_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_entries FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_write_guard();
--> statement-breakpoint
CREATE FUNCTION staff_time_span_overlap_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || NEW.tenant_id || ':' || NEW.user_id,0));
IF EXISTS(SELECT 1 FROM staff_time_spans s WHERE s.tenant_id=NEW.tenant_id AND s.user_id=NEW.user_id AND s.id<>NEW.id
AND s.started_at<COALESCE(NEW.ended_at,'infinity'::timestamptz) AND COALESCE(s.ended_at,'infinity'::timestamptz)>NEW.started_at) THEN
RAISE EXCEPTION 'Überlappende Zeitspannen' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER staff_time_span_no_overlap BEFORE INSERT OR UPDATE ON staff_time_spans FOR EACH ROW EXECUTE FUNCTION staff_time_span_overlap_guard();
--> statement-breakpoint
CREATE FUNCTION staff_time_legacy_project_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE parent staff_time_entries;
BEGIN
IF TG_OP = 'DELETE' THEN SELECT * INTO parent FROM staff_time_entries WHERE id=OLD.time_entry_id;
ELSE SELECT * INTO parent FROM staff_time_entries WHERE id=NEW.time_entry_id; END IF;
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || parent.tenant_id || ':' || parent.user_id,0));
IF EXISTS(SELECT 1 FROM staff_time_v2_accounts WHERE tenant_id=parent.tenant_id AND user_id=parent.user_id) THEN
RAISE EXCEPTION 'Projektzeiten unter 2.0 bearbeiten.' USING ERRCODE='23514';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER staff_time_project_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_entry_connects FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_project_guard();

View File

@@ -1,22 +0,0 @@
ALTER TABLE communication_messages ADD COLUMN image jsonb;
--> statement-breakpoint
ALTER TABLE collaboration_messages ADD COLUMN image jsonb;
--> statement-breakpoint
ALTER TABLE collaboration_messages DROP CONSTRAINT collaboration_messages_body_check;
--> statement-breakpoint
ALTER TABLE collaboration_messages ADD CONSTRAINT collaboration_messages_body_check
CHECK (length(body) <= 10000 AND (length(body) > 0 OR image IS NOT NULL));
--> statement-breakpoint
CREATE TABLE communication_message_reactions (
message_id bigint NOT NULL REFERENCES communication_messages(id) ON DELETE CASCADE,
actor_id uuid NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
emoji text NOT NULL CHECK (length(emoji) BETWEEN 1 AND 16),
PRIMARY KEY (message_id, actor_id, emoji)
);
--> statement-breakpoint
CREATE TABLE collaboration_message_reactions (
message_id uuid NOT NULL REFERENCES collaboration_messages(id) ON DELETE CASCADE,
actor_id uuid NOT NULL REFERENCES collaboration_principals(id) ON DELETE CASCADE,
emoji text NOT NULL CHECK (length(emoji) BETWEEN 1 AND 16),
PRIMARY KEY (message_id, actor_id, emoji)
);

View File

@@ -1,3 +0,0 @@
ALTER TABLE "auth_users" ADD COLUMN "archived_at" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD COLUMN "archived_at" timestamp with time zone;

View File

@@ -1,7 +0,0 @@
INSERT INTO notifications_event_types (event_key, display_name, description, category, allowed_channels) VALUES
('collaboration.message.new', 'Zusammenarbeit: Chatnachrichten', 'Neue Nachrichten in freigegebenen Unterräumen und föderierten Chats.', 'collaboration', '["inapp","email","push"]'),
('collaboration.room.invited', 'Zusammenarbeit: Einladungen', 'Persönliche Einladungen in freigegebene Unterräume und föderierte Chats.', 'collaboration', '["inapp","email","push"]')
ON CONFLICT (event_key) DO NOTHING;
--> statement-breakpoint
CREATE INDEX notifications_collaboration_queued ON notifications_items(created_at)
WHERE status='queued' AND event_type IN ('collaboration.message.new','collaboration.room.invited');

View File

@@ -526,48 +526,6 @@
"when": 1790899200000,
"tag": "0077_scanner_inbox_preparation",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1790985600000,
"tag": "0078_chat_visibility",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1791072000000,
"tag": "0079_notification_matrix",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1791158400000,
"tag": "0080_staff_time_spans",
"breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1791244800000,
"tag": "0081_chat_media_reactions",
"breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1792022400000,
"tag": "0082_archive_users",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1792022400001,
"tag": "0083_collaboration_notifications",
"breakpoints": true
}
]
}

View File

@@ -82,7 +82,6 @@ export const authProfiles = pgTable("auth_profiles", {
address_city: text("address_city"),
active: boolean("active").notNull().default(true),
archived_at: timestamp("archived_at", { withTimezone: true }),
})
export type AuthProfile = typeof authProfiles.$inferSelect

View File

@@ -13,7 +13,6 @@ export const authUsers = pgTable("auth_users", {
multiTenant: boolean("multi_tenant").notNull().default(true),
must_change_password: boolean("must_change_password").notNull().default(false),
is_admin: boolean("is_admin").notNull().default(false),
archived_at: timestamp("archived_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true }),

View File

@@ -1,19 +0,0 @@
import { sql } from "drizzle-orm"
import { bigint, check, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
import { collaborationMessages, collaborationPrincipals } from "./collaboration"
import { communicationMessages } from "./communication_messages"
export const communicationMessageReactions = pgTable("communication_message_reactions", {
messageId: bigint("message_id", { mode: "number" }).notNull().references(() => communicationMessages.id, { onDelete: "cascade" }),
actorId: uuid("actor_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
emoji: text("emoji").notNull(),
}, t => ({ pk: primaryKey({ columns: [t.messageId, t.actorId, t.emoji] }),
emoji: check("communication_message_reactions_emoji_check", sql`length(${t.emoji}) BETWEEN 1 AND 16`) }))
export const collaborationMessageReactions = pgTable("collaboration_message_reactions", {
messageId: uuid("message_id").notNull().references(() => collaborationMessages.id, { onDelete: "cascade" }),
actorId: uuid("actor_id").notNull().references(() => collaborationPrincipals.id, { onDelete: "cascade" }),
emoji: text("emoji").notNull(),
}, t => ({ pk: primaryKey({ columns: [t.messageId, t.actorId, t.emoji] }),
emoji: check("collaboration_message_reactions_emoji_check", sql`length(${t.emoji}) BETWEEN 1 AND 16`) }))

View File

@@ -50,10 +50,10 @@ export const collaborationMembers = pgTable("collaboration_members", {
export const collaborationMessages = pgTable("collaboration_messages", {
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
seq: bigint("seq", { mode: "number" }).notNull(), principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id),
clientId: uuid("client_id").notNull(), body: text("body").notNull(), image: jsonb("image"), createdAt: time("created_at").notNull().defaultNow(),
clientId: uuid("client_id").notNull(), body: text("body").notNull(), createdAt: time("created_at").notNull().defaultNow(),
}, t => ({ seq: uniqueIndex("collaboration_messages_room_id_seq_key").on(t.roomId, t.seq),
command: uniqueIndex("collaboration_messages_room_id_principal_id_client_id_key").on(t.roomId, t.principalId, t.clientId),
body: check("collaboration_messages_body_check", sql`length(${t.body}) <= 10000 AND (length(${t.body}) > 0 OR ${t.image} IS NOT NULL)`) }))
body: check("collaboration_messages_body_check", sql`length(${t.body}) BETWEEN 1 AND 10000`) }))
export const collaborationInvitations = pgTable("collaboration_invitations", {
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
targetPrincipalId: uuid("target_principal_id").notNull().references(() => collaborationPrincipals.id), tokenHash: text("token_hash").notNull().unique(),

View File

@@ -1,10 +0,0 @@
import { bigint, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
import { tenants } from "./tenants"
// Room keys also cover collaboration bindings and not-yet-provisioned chats.
export const communicationHiddenRooms = pgTable("communication_hidden_rooms", {
tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }),
userId: uuid("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
roomKey: text("room_key").notNull(),
}, table => ({ pk: primaryKey({ columns: [table.tenantId, table.userId, table.roomKey] }) }))

View File

@@ -1,4 +1,4 @@
import { bigint, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
import { communicationRooms } from "./communication_rooms"
@@ -20,7 +20,6 @@ export const communicationMessages = pgTable(
.notNull()
.references(() => authUsers.id),
body: text("body").notNull(),
image: jsonb("image"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),

View File

@@ -19,7 +19,6 @@ export * from "./communication_rooms"
export * from "./communication_room_members"
export * from "./communication_messages"
export * from "./communication_room_reads"
export * from "./communication_hidden_rooms"
export * from "./contacts"
export * from "./contracts"
export * from "./contracttypes"
@@ -105,6 +104,3 @@ export * from "./scanner_inbox"
export * from "./collaboration";
export * from "./collaboration_federation"
export * from './staff_time_spans'
export * from "./chat_reactions"

View File

@@ -5,9 +5,7 @@ import {
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
@@ -50,8 +48,7 @@ export const notificationsItems = pgTable("notifications_items", {
sentAt: timestamp("sent_at", { withTimezone: true }),
readAt: timestamp("read_at", { withTimezone: true }),
}, t => ({ collaborationQueued: index("notifications_collaboration_queued").on(t.createdAt)
.where(sql`${t.status}='queued' AND ${t.eventType} IN ('collaboration.message.new','collaboration.room.invited')`) }))
})
export type NotificationItem = typeof notificationsItems.$inferSelect
export type NewNotificationItem = typeof notificationsItems.$inferInsert

View File

@@ -66,8 +66,6 @@ export const projects = pgTable("projects", {
archived: boolean("archived").notNull().default(false),
chatEnabled: boolean("chat_enabled").notNull().default(true),
customerRef: text("customerRef"),
updatedAt: timestamp("updated_at", { withTimezone: true }),

View File

@@ -14,7 +14,7 @@ export const serialExecutions = pgTable("serial_executions", {
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id), executionDate: timestamp("execution_date").notNull(),
status: text("status").default("draft"), // generating, draft, processing, cancelling, cancelled, completed, error
status: text("status").default("draft"), // 'draft', 'completed'
createdBy: text("created_by"), // oder UUID, je nach Auth-System
createdAt: timestamp("created_at").defaultNow(),
summary: text("summary"), // z.B. "25 Rechnungen erstellt"

View File

@@ -1,48 +0,0 @@
import { pgTable, uuid, bigint, text, timestamp, date, integer, jsonb, primaryKey, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { tenants } from './tenants';
import { authUsers } from './auth_users';
import { projects } from './projects';
const stamp = (name: string) => timestamp(name, { withTimezone: true });
export const staffTimeV2Accounts = pgTable('staff_time_v2_accounts', {
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull().references(() => tenants.id),
user_id: uuid('user_id').notNull().references(() => authUsers.id),
activated_at: stamp('activated_at').notNull().defaultNow(), activated_by: uuid('activated_by').references(() => authUsers.id),
}, t => [primaryKey({ columns: [t.tenant_id, t.user_id] })]);
export const staffTimeSpans = pgTable('staff_time_spans', {
id: uuid('id').primaryKey().defaultRandom(), tenant_id: bigint('tenant_id', { mode: 'number' }).notNull().references(() => tenants.id),
user_id: uuid('user_id').notNull().references(() => authUsers.id), type: text('type').notNull(),
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at'), date_from: date('date_from'), date_to: date('date_to'),
timezone: text('timezone').notNull().default('Europe/Berlin'), description: text('description').notNull().default(''), source: text('source').notNull(),
status: text('status').notNull().default('factual'), version: integer('version').notNull().default(1),
created_at: stamp('created_at').notNull().defaultNow(), updated_at: stamp('updated_at').notNull().defaultNow(),
}, t => [index('staff_time_spans_user_time').on(t.tenant_id, t.user_id, t.started_at),
uniqueIndex('staff_time_spans_one_open').on(t.tenant_id, t.user_id).where(sql `${t.ended_at} IS NULL`),
check('staff_time_spans_check', sql `${t.ended_at} IS NULL OR ${t.ended_at} > ${t.started_at}`),
check('staff_time_spans_type_check', sql `${t.type} IN ('work','vacation','sick','overtime_compensation','holiday','other')`),
check('staff_time_spans_status_check', sql `${t.status} IN ('factual','submitted','approved','rejected')`),
check('staff_time_spans_version_check', sql `${t.version} > 0`),
check('staff_time_spans_check1', sql `(${t.date_from} IS NULL AND ${t.date_to} IS NULL) OR (${t.type} <> 'work' AND ${t.date_from} IS NOT NULL AND ${t.date_to} IS NOT NULL AND ${t.date_to} > ${t.date_from} AND ${t.ended_at} IS NOT NULL)`),
check('staff_time_spans_check2', sql `${t.status} = 'factual' OR ${t.ended_at} IS NOT NULL`)]);
export const staffTimeSpanBreaks = pgTable('staff_time_span_breaks', {
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at'),
}, t => [index('staff_time_span_breaks_parent').on(t.span_id), uniqueIndex('staff_time_span_breaks_one_open').on(t.span_id).where(sql `${t.ended_at} IS NULL`), check('staff_time_span_breaks_check', sql `${t.ended_at} IS NULL OR ${t.ended_at} > ${t.started_at}`)]);
export const staffTimeSpanProjects = pgTable('staff_time_span_projects', {
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
project_id: bigint('project_id', { mode: 'number' }).notNull().references(() => projects.id),
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at').notNull(), notes: text('notes').notNull().default(''),
}, t => [index('staff_time_span_projects_parent').on(t.span_id), check('staff_time_span_projects_check', sql `${t.ended_at} > ${t.started_at}`)]);
export const staffTimeSpanHistory = pgTable('staff_time_span_history', {
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
actor_id: uuid('actor_id').references(() => authUsers.id), action: text('action').notNull(), reason: text('reason').notNull().default(''),
before_data: jsonb('before_data'), after_data: jsonb('after_data').notNull(), created_at: stamp('created_at').notNull().defaultNow(),
}, t => [index('staff_time_span_history_parent').on(t.span_id, t.created_at)]);
export const staffTimeV2Requests = pgTable('staff_time_v2_requests', {
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull(), user_id: uuid('user_id').notNull(), request_id: text('request_id').notNull(),
fingerprint: text('fingerprint').notNull(), response: jsonb('response').notNull(), created_at: stamp('created_at').notNull().defaultNow(),
}, t => [primaryKey({ columns: [t.tenant_id, t.user_id, t.request_id] })]);
export const staffTimeSpanOrigins = pgTable('staff_time_span_origins', {
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull(), source_kind: text('source_kind').notNull(), source_id: uuid('source_id').notNull(),
span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
}, t => [primaryKey({ columns: [t.tenant_id, t.source_kind, t.source_id] })]);

View File

@@ -26,7 +26,6 @@ import helpdeskRoutes from "./routes/helpdesk";
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
import notificationsRoutes from "./routes/notifications";
import staffTimeRoutes from "./routes/staff/time";
import timeV2Routes from "./routes/staff/time-v2";
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user";
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
@@ -169,7 +168,6 @@ async function main() {
await subApp.register(helpdeskRoutes);
await subApp.register(notificationsRoutes);
await subApp.register(staffTimeRoutes);
await subApp.register(timeV2Routes);
await subApp.register(staffTimeConnectRoutes);
await subApp.register(userRoutes);
await subApp.register(publiclinksAuthenticatedRoutes);

View File

@@ -71,7 +71,7 @@ export async function createMcpContext(
}
}
export function assertToolPermission(context: McpContext, tool: Pick<McpTool, "name" | "requiredPermissions">) {
export function assertToolPermission(context: McpContext, tool: McpTool) {
if (context.isAdmin) return
const allowed = tool.requiredPermissions.every((permission) =>
@@ -85,3 +85,4 @@ export function assertToolPermission(context: McpContext, tool: Pick<McpTool, "n
)
}
}

View File

@@ -1,44 +0,0 @@
import { and, eq, getTableColumns } from "drizzle-orm"
import { authProfiles, files } from "../../db/schema"
import { McpContext } from "./types"
import { validateScannerFile } from "../modules/scanner-inbox.service"
import { secrets } from "../utils/secrets"
export const MCP_FILE_LIMIT = 20 * 1024 * 1024
export const MCP_BODY_LIMIT = Math.ceil(MCP_FILE_LIMIT / 3) * 4 + 64 * 1024
export const MCP_FILE_PERMISSION = "documents.read"
// Storage paths and potentially large extracted text do not belong in list responses.
const { path: _path, extractedText: _text, ...metadata } = getTableColumns(files)
export const fileMetadataColumns = metadata
export async function assertInternalDocumentAccess(context: McpContext) {
const [profile] = await context.server.db.select({ portal: authProfiles.customer_for_portal }).from(authProfiles)
.where(and(eq(authProfiles.tenant_id, context.tenantId), eq(authProfiles.user_id, context.userId))).limit(1)
if (profile?.portal) throw Object.assign(new Error("Die Dokumentenablage ist nur intern verfügbar."), { statusCode: 403 })
}
export async function loadDocumentFile(context: McpContext, id: string) {
const [file] = await context.server.db.select().from(files)
.where(and(eq(files.id, id), eq(files.tenant, context.tenantId), eq(files.archived, false))).limit(1)
if (!file) throw Object.assign(new Error("Datei nicht gefunden oder archiviert."), { statusCode: 404 })
return file
}
export function decodeDocumentUpload(filename: string, value: unknown) {
if (typeof value !== "string" || !value.length) throw new Error("contentBase64 ist erforderlich.")
if (value.length > Math.ceil(MCP_FILE_LIMIT / 3) * 4) throw new Error("MCP-Uploads sind auf 20 MiB begrenzt.")
// Buffer.from silently accepts invalid characters; require canonical, padded Base64.
if (value.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value)) throw new Error("contentBase64 muss gültiges Base64 ohne Data-URL-Präfix sein.")
const body = Buffer.from(value, "base64")
if (body.toString("base64") !== value) throw new Error("contentBase64 muss gültiges Base64 sein.")
if (body.length > MCP_FILE_LIMIT) throw new Error("MCP-Uploads sind auf 20 MiB begrenzt.")
const mimeType = validateScannerFile(filename, body)
return { body, mimeType }
}
export function documentDownloadUrl(id: string) {
const path = `/api/mcp/files/${id}/content`
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
return base ? `${base}${path}` : path
}

View File

@@ -1,93 +0,0 @@
import { assertToolPermission } from "./authz"
import { assertInternalDocumentAccess, MCP_FILE_LIMIT } from "./documentFiles"
import { McpContext, McpTool } from "./types"
import { createScannerInboxService, validateScannerFile } from "../modules/scanner-inbox.service"
import { uploadIncomingInvoiceFile } from "./tools/accounting"
import { secrets } from "../utils/secrets"
export const MCP_UPLOAD_PATH = "/api/mcp/files"
export const uploadTargets = ["inbox", "incoming_invoice"] as const
export type UploadTarget = typeof uploadTargets[number]
export type McpFileUpload = {
target: UploadTarget
filename: string
body: Buffer
mimeType?: string | null
invoiceId?: number | null
prepare?: boolean
}
export function mcpUploadUrl() {
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
return base ? `${base}${MCP_UPLOAD_PATH}` : MCP_UPLOAD_PATH
}
const badRequest = (message: string, statusCode = 400) => Object.assign(new Error(message), { statusCode })
/** Reads the query options of the binary upload endpoint. */
export function parseUploadQuery(query: Record<string, unknown>) {
const target = (query.target ?? "inbox") as UploadTarget
if (!uploadTargets.includes(target)) throw badRequest(`target muss ${uploadTargets.join(" oder ")} sein.`)
let invoiceId: number | null = null
if (query.invoiceId !== undefined && query.invoiceId !== "") {
invoiceId = Number(query.invoiceId)
if (!Number.isSafeInteger(invoiceId) || invoiceId < 1) throw badRequest("invoiceId muss eine positive ganze Zahl sein.")
}
if (query.prepare !== undefined && !["true", "false"].includes(String(query.prepare))) throw badRequest("prepare muss true oder false sein.")
if (target === "inbox" && (invoiceId || query.prepare !== undefined)) throw badRequest("invoiceId und prepare gelten nur für target=incoming_invoice.")
const filename = typeof query.filename === "string" && query.filename.trim() ? query.filename.trim() : null
return { target, invoiceId, prepare: String(query.prepare) !== "false", filename }
}
export async function storeMcpFileUpload(context: McpContext, upload: McpFileUpload) {
if (!upload.body.length) throw badRequest("Die Datei ist leer.")
if (upload.body.length > MCP_FILE_LIMIT) throw badRequest("MCP-Uploads sind auf 20 MiB begrenzt.", 413)
if (upload.target === "inbox") {
assertToolPermission(context, { name: "documents.files.upload", requiredPermissions: ["documents.write"] })
await assertInternalDocumentAccess(context)
const mimeType = validateScannerFile(upload.filename, upload.body)
const result = await createScannerInboxService(context.server.db).upload(context.tenantId, context.userId, upload.filename, upload.body)
return { ...result, filename: upload.filename, mimeType, size: upload.body.length }
}
assertToolPermission(context, { name: "accounting.incoming_invoices.files.upload", requiredPermissions: ["accounting.incoming_invoices.write"] })
return uploadIncomingInvoiceFile(context, {
filename: upload.filename,
buffer: upload.body,
mimeType: upload.mimeType || "application/octet-stream",
invoiceId: upload.invoiceId,
prepare: upload.prepare,
})
}
export const uploadUrlTool: McpTool = {
name: "documents.files.upload_url",
title: "Upload-Adresse für Dateien",
description: "Liefert die HTTP-Adresse, über die Dateien ohne Base64 direkt hochgeladen werden (multipart/form-data oder Rohdaten, maximal 20 MiB). Für Uploads mit Dateizugriff, z. B. per curl, statt documents.files.upload oder accounting.incoming_invoices.files.upload verwenden.",
// Informational only; the endpoint checks the permission of the chosen target.
requiredPermissions: [],
inputSchema: { type: "object", properties: {}, additionalProperties: false },
async handler() {
const url = mcpUploadUrl()
return {
url,
method: "POST",
authentication: "Authorization: Bearer <derselbe MCP-Token>",
bodies: {
multipart: "multipart/form-data mit dem Dateifeld file; der Dateiname wird aus dem Upload übernommen.",
raw: "Dateiinhalt als Body mit passendem Content-Type (application/pdf, image/jpeg, image/png, image/tiff, application/xml oder application/octet-stream) und Query-Parameter filename.",
},
query: {
target: "inbox (Standard, Dokumentenablage, benötigt documents.write) oder incoming_invoice (Eingangsrechnung, benötigt accounting.incoming_invoices.write).",
filename: "Dateiname; bei Rohdaten erforderlich, bei multipart optional zum Überschreiben.",
invoiceId: "Nur incoming_invoice: vorhandenen Eingangsbeleg verknüpfen.",
prepare: "Nur incoming_invoice: false unterdrückt die automatische Belegvorbereitung (Standard true).",
},
response: "JSON wie beim entsprechenden Base64-Werkzeug.",
examples: [
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -F "file=@rechnung.pdf" "${url}?target=incoming_invoice"`,
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -H "Content-Type: application/pdf" --data-binary @scan.pdf "${url}?filename=scan.pdf"`,
],
}
},
}

View File

@@ -2,18 +2,12 @@ import { accountingTools } from "./tools/accounting"
import { masterdataTools } from "./tools/masterdata"
import { organisationTools } from "./tools/organisation"
import { wikiTools } from "./tools/wiki"
import { documentTools } from "./tools/documents"
import { archiveTools } from "./tools/archive"
import { uploadUrlTool } from "./fileUploads"
export const mcpTools = [
...accountingTools,
...masterdataTools,
...organisationTools,
...wikiTools,
...documentTools,
...archiveTools,
uploadUrlTool,
]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

View File

@@ -1,6 +1,6 @@
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, inArray, or } from "drizzle-orm"
import { and, desc, eq, ilike, or } from "drizzle-orm"
import { randomUUID } from "node:crypto"
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
import {
@@ -22,15 +22,11 @@ import { saveFile } from "../../utils/files"
import { insertHistoryItem } from "../../utils/history"
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
import { updateIncomingInvoiceCostCentres } from "../../modules/incoming-invoice-cost-centres.service"
import { loadBankBalances } from "../../modules/bank-balances.service"
import { dateField } from "../masterdataFields"
import {
prepareStatementAllocationInput,
statementAllocationUuidArg,
} from "../statementAllocations"
import { McpContext, McpTool } from "../types"
import { outgoingStatusTools } from "./outgoingStatus"
import { McpTool } from "../types"
export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -312,98 +308,6 @@ const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId:
}
}
export type IncomingInvoiceFileUpload = {
filename: string
buffer: Buffer
mimeType: string
invoiceId?: number | null
invoiceArgs?: Record<string, unknown> | null
prepare?: boolean
}
/** Shared by the Base64 tool and the binary MCP upload endpoint. */
export async function uploadIncomingInvoiceFile(context: McpContext, upload: IncomingInvoiceFileUpload) {
const { filename, buffer, mimeType } = upload
const invoiceId = upload.invoiceId || null
const invoiceArgs = upload.invoiceArgs || null
const shouldPrepare = upload.prepare !== false && !invoiceId && !invoiceArgs
if (!buffer.length) throw new Error("Datei ist leer")
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
let linkedInvoice: any = null
if (invoiceId) {
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
linkedInvoice = existing
} else if (invoiceArgs) {
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
payload.state = "Entwurf"
const [createdInvoice] = await context.server.db
.insert(incominginvoices)
.values(payload as any)
.returning()
linkedInvoice = createdInvoice
}
const defaults = await loadIncomingInvoiceFileDefaults(context)
const saved = await saveFile(
context.server,
context.tenantId,
null,
{
filename,
content: buffer,
contentType: mimeType,
},
defaults.folderId,
defaults.fileTagId,
{
incominginvoice: linkedInvoice?.id || null,
createdBy: context.userId,
updatedBy: context.userId,
updatedAt: new Date(),
}
)
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
if (shouldPrepare) {
// Only the uploaded file; other open invoice files stay for an explicit preparation run.
await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds: [saved.id] })
}
const [file] = await context.server.db
.select()
.from(files)
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
.limit(1)
if (file?.incominginvoice && !linkedInvoice) {
const [preparedInvoice] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
linkedInvoice = preparedInvoice || null
}
return {
file,
invoice: linkedInvoice,
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
prepared: Boolean(shouldPrepare && linkedInvoice),
}
}
const buildIncomingInvoicePayload = (
args: Record<string, unknown>,
userId: string,
@@ -519,7 +423,6 @@ const assertStatementAllocationReferences = async (context: any, payload: Record
}
export const accountingTools: McpTool[] = [
...outgoingStatusTools,
{
name: "accounting.outgoing_documents.tax_types.list",
title: "Steuertypen für Ausgangsbelege auflisten",
@@ -899,7 +802,7 @@ export const accountingTools: McpTool[] = [
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["generating", "draft", "processing", "cancelling", "cancelled", "completed", "error"] },
status: { type: "string", enum: ["draft", "completed", "error"] },
...paginationProperties,
},
},
@@ -1106,7 +1009,7 @@ export const accountingTools: McpTool[] = [
{
name: "accounting.incoming_invoices.files.upload",
title: "Datei für Eingangsbeleg hochladen",
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) hochladen.",
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
@@ -1142,14 +1045,83 @@ export const accountingTools: McpTool[] = [
const filename = stringArg(args, "filename")
if (!filename) throw new Error("filename ist erforderlich")
return uploadIncomingInvoiceFile(context, {
filename,
buffer: base64BufferArg(args, "contentBase64"),
mimeType: stringArg(args, "mimeType") || "application/octet-stream",
invoiceId: numberArg(args, "invoiceId"),
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
prepare: args.prepare !== false,
})
const buffer = base64BufferArg(args, "contentBase64")
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
const invoiceId = numberArg(args, "invoiceId")
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
let linkedInvoice: any = null
if (invoiceId) {
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
linkedInvoice = existing
} else if (invoiceArgs) {
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
payload.state = "Entwurf"
const [createdInvoice] = await context.server.db
.insert(incominginvoices)
.values(payload as any)
.returning()
linkedInvoice = createdInvoice
}
const defaults = await loadIncomingInvoiceFileDefaults(context)
const saved = await saveFile(
context.server,
context.tenantId,
null,
{
filename,
content: buffer,
contentType: mimeType,
},
defaults.folderId,
defaults.fileTagId,
{
incominginvoice: linkedInvoice?.id || null,
createdBy: context.userId,
updatedBy: context.userId,
updatedAt: new Date(),
}
)
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
if (shouldPrepare) {
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
}
const [file] = await context.server.db
.select()
.from(files)
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
.limit(1)
if (file?.incominginvoice && !linkedInvoice) {
const [preparedInvoice] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
linkedInvoice = preparedInvoice || null
}
return {
file,
invoice: linkedInvoice,
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
prepared: Boolean(shouldPrepare && linkedInvoice),
}
},
},
{
@@ -1178,62 +1150,6 @@ export const accountingTools: McpTool[] = [
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
},
},
{
name: "accounting.incoming_invoices.prepare",
title: "Belegvorbereitung starten",
description: "Startet die Belegvorbereitung wie der Button „Belege vorbereiten“: Dateien vom Typ Eingangsrechnung ohne verknüpften Beleg werden ausgelesen (strukturierte E-Rechnung, sonst KI) und als Eingangsbeleg-Entwurf angelegt. Optional nur für bestimmte Datei-IDs. Kann je Datei einige Sekunden dauern.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
properties: {
fileIds: {
type: "array",
items: { type: "string", format: "uuid" },
minItems: 1,
maxItems: 50,
description: "Optionale Datei-IDs; ohne Angabe alle offenen Eingangsrechnungsdateien.",
},
},
additionalProperties: false,
},
async handler(context, args) {
let fileIds: string[] | undefined
if (args.fileIds !== undefined) {
if (!Array.isArray(args.fileIds) || !args.fileIds.length || args.fileIds.length > 50) {
throw new Error("fileIds muss 1 bis 50 Datei-IDs enthalten")
}
if (args.fileIds.some((id) => typeof id !== "string" || !UUID_PATTERN.test(id))) {
throw new Error("fileIds darf nur gültige UUIDs enthalten")
}
fileIds = [...new Set((args.fileIds as string[]).map((id) => id.toLowerCase()))]
}
const result = await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds })
if (result.error && !result.filesFound) throw new Error(result.error)
const invoiceIds = result.created.map((entry) => entry.invoiceId)
const invoices = invoiceIds.length
? await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.tenant, context.tenantId), inArray(incominginvoices.id, invoiceIds)))
: []
const handled = new Set([...result.created, ...result.skipped].map((entry) => entry.fileId))
return {
filesFound: result.filesFound,
created: result.created.map((entry) => {
const invoice = invoices.find((row) => row.id === entry.invoiceId) || null
return { ...entry, invoice, validation: invoice ? validateIncomingInvoiceData(invoice as Record<string, any>) : null }
}),
skipped: [
...result.skipped,
...(fileIds || []).filter((id) => !handled.has(id))
.map((fileId) => ({ fileId, reason: "Keine offene Eingangsrechnungsdatei (nicht gefunden, archiviert, anderer Dateityp oder bereits vorbereitet)" })),
],
}
},
},
{
name: "accounting.incoming_invoices.create",
title: "Eingangsbeleg erstellen",
@@ -1311,44 +1227,6 @@ export const accountingTools: McpTool[] = [
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
},
},
{
name: "accounting.incoming_invoices.cost_centres.update",
title: "Kostenstellen eines Eingangsbelegs ändern",
description: "Ändert ausschließlich die Positionskostenstellen eines Eingangsbelegs, auch wenn er gebucht oder einer Bankbuchung zugewiesen ist.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
required: ["id", "accountCostCentres"],
properties: {
id: { type: "number" },
accountCostCentres: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["index", "costcentre"],
properties: {
index: { type: "integer", minimum: 0, description: "Nullbasierter Index der Position in accounts." },
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
},
},
},
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const invoice = await updateIncomingInvoiceCostCentres(
context.server,
context.tenantId,
context.userId,
id,
{ accountCostCentres: args.accountCostCentres as any },
)
return { invoice }
},
},
{
name: "accounting.incoming_invoices.book",
title: "Eingangsbeleg buchen",
@@ -1430,26 +1308,6 @@ export const accountingTools: McpTool[] = [
return { invoice: updated }
},
},
{
name: "accounting.bank_accounts.balances",
title: "Kontostände abfragen",
description: "Liefert die Kontostände der Bankkonten und Kassenbücher des aktiven Mandanten samt Summe. Bankkonten zeigen den zuletzt synchronisierten Buchungssaldo; mit asOf wird der Stand zu einem vergangenen Datum aus den Bankumsätzen zurückgerechnet. Kassenbücher ergeben sich aus Anfangsbestand und Buchungen.",
requiredPermissions: ["accounting.bank.read"],
inputSchema: {
type: "object",
properties: {
account: { type: "number", description: "Optionale Bankkonto-ID; ohne Angabe alle Konten." },
asOf: { ...dateField.schema, description: "Optionaler Stichtag YYYY-MM-DD (Stand zum Tagesende)." },
},
},
async handler(context, args) {
const account = numberArg(args, "account")
const asOf = args.asOf === undefined || args.asOf === null ? null : dateField.parse(args.asOf, "asOf")
const result = await loadBankBalances(context.server, context.tenantId, { account, asOf })
if (account && !result.accounts.length) throw new Error("Bankkonto nicht gefunden")
return result
},
},
{
name: "accounting.bank_statements.list",
title: "Bankumsätze auflisten",

View File

@@ -1,150 +0,0 @@
import { and, desc, eq, getTableColumns, ilike, or } from "drizzle-orm"
import * as tables from "../../../db/schema"
import { assertToolPermission } from "../authz"
import { assertInternalDocumentAccess } from "../documentFiles"
import { idField, InputField, numericField, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
import { insertHistoryItem } from "../../utils/history"
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
type ArchiveEntity = {
label: string
table: any
/** Key used by the history log and the generic resource routes. */
resource: string
permission: string
searchColumns: string[]
uuid?: boolean
internalDocuments?: boolean
}
const masterdata = (label: string, table: any, resource: string, namespace: string, searchColumns: string[], uuid = false): ArchiveEntity =>
({ label, table, resource, permission: `masterdata.${namespace}`, searchColumns, uuid })
/** Every entity the MCP can archive; restore checks the matching write permission. */
export const archiveEntities: Record<string, ArchiveEntity> = {
customers: masterdata("Kunde", tables.customers, "customers", "customers", ["name", "customerNumber"]),
vendors: masterdata("Lieferant", tables.vendors, "vendors", "vendors", ["name", "vendorNumber"]),
contacts: masterdata("Kontakt", tables.contacts, "contacts", "contacts", ["fullName", "email"]),
products: masterdata("Artikel", tables.products, "products", "products", ["name", "article_number"]),
services: masterdata("Leistung", tables.services, "services", "services", ["name"]),
cost_centres: masterdata("Kostenstelle", tables.costcentres, "costcentres", "cost_centres", ["name", "number"], true),
branches: masterdata("Niederlassung", tables.branches, "branches", "branches", ["name", "number"]),
teams: masterdata("Team", tables.teams, "teams", "teams", ["name"]),
vehicles: masterdata("Fahrzeug", tables.vehicles, "vehicles", "vehicles", ["name", "license_plate"]),
inventory_items: masterdata("Inventar", tables.inventoryitems, "inventoryitems", "inventory_items", ["name", "articleNumber"]),
projects: { label: "Projekt", table: tables.projects, resource: "projects", permission: "organisation.projects", searchColumns: ["name", "projectNumber"] },
events: { label: "Termin", table: tables.events, resource: "events", permission: "organisation.events", searchColumns: ["name"] },
tasks: { label: "Aufgabe", table: tables.tasks, resource: "tasks", permission: "organisation.tasks", searchColumns: ["name"] },
outgoing_documents: { label: "Ausgangsbeleg", table: tables.createddocuments, resource: "createddocuments", permission: "accounting.outgoing_documents", searchColumns: ["documentNumber", "title"] },
incoming_invoices: { label: "Eingangsbeleg", table: tables.incominginvoices, resource: "incominginvoices", permission: "accounting.incoming_invoices", searchColumns: ["reference", "description"] },
files: { label: "Datei", table: tables.files, resource: "files", permission: "documents", searchColumns: ["name"], uuid: true, internalDocuments: true },
}
const entityField: InputField = {
schema: { type: "string", enum: Object.keys(archiveEntities), description: "Art des archivierten Datensatzes." },
parse(value, path) {
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(archiveEntities, value)) throw new Error(`${path}: unbekannte Entität`)
return value
},
}
const entityIdField: InputField = {
schema: { type: ["integer", "string"], description: "ID des Datensatzes; UUID bei cost_centres und files, sonst ganze Zahl." },
parse: (value) => value,
}
const searchField: InputField = {
schema: { type: "string", maxLength: 200, description: "Optionaler Suchtext, z. B. Name oder Nummer." },
parse(value, path) {
const text = textField().parse(value, path)
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
return text
},
}
const listInput = objectField({
entity: entityField,
search: searchField,
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
})
const restoreInput = objectField({ entity: entityField, id: entityIdField })
const columnKey = (table: any, ...names: string[]) => names.find((name) => name in getTableColumns(table))
/** asToolResult hides records flagged archived; archived rows are marked explicitly instead. */
const withoutArchivedFlag = ({ archived: _archived, path: _path, extractedText: _text, ...row }: Record<string, any>) => row
async function authorize(context: McpContext, tool: McpTool, entity: ArchiveEntity, access: "read" | "write") {
assertToolPermission(context, { name: tool.name, requiredPermissions: [`${entity.permission}.${access}`] })
if (entity.internalDocuments) await assertInternalDocumentAccess(context)
}
export function prepareArchiveRestoreInput(args: Record<string, unknown>) {
const input = restoreInput.parse(args, "arguments")
if (!input.entity) throw new Error("entity ist erforderlich")
if (input.id === undefined || input.id === null) throw new Error("id ist erforderlich")
const entity = archiveEntities[input.entity]
return { key: input.entity as string, entity, id: (entity.uuid ? uuidField : idField).parse(input.id, "id") }
}
const listTool: McpTool = {
name: "archive.entities.list",
title: "Archivierte Einträge auflisten",
description: "Listet archivierte Datensätze einer Entitätsart im aktiven Mandanten, zuletzt geänderte zuerst. IDs anschließend mit archive.entities.restore wiederherstellen.",
// Checked per entity in the handler.
requiredPermissions: [],
inputSchema: { ...listInput.schema, required: ["entity"] },
async handler(context, args) {
const input = listInput.parse(args, "arguments")
if (!input.entity) throw new Error("entity ist erforderlich")
const entity = archiveEntities[input.entity]
await authorize(context, listTool, entity, "read")
const table = entity.table
const conditions: any[] = [eq(table.tenant, context.tenantId), eq(table.archived, true)]
if (input.search) {
const pattern = `%${input.search.replace(/[\\%_]/g, "\\$&")}%`
conditions.push(or(...entity.searchColumns.map((column) => ilike(table[column], pattern))))
}
const updatedAt = columnKey(table, "updatedAt", "updated_at")!
const rows = await context.server.db.select().from(table).where(and(...conditions))
.orderBy(desc(table[updatedAt]), desc(table.id)).limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
const result = paginatedResult(rows, input)
return { ...result, entity: input.entity, rows: result.rows.map((row: any) => ({ ...withoutArchivedFlag(row), isArchived: true })) }
},
}
const restoreTool: McpTool = {
name: "archive.entities.restore",
title: "Archivierten Eintrag wiederherstellen",
description: `Stellt einen archivierten Datensatz im aktiven Mandanten wieder her (entity: ${Object.keys(archiveEntities).join(", ")}). Benötigt die Schreibberechtigung der jeweiligen Entität.`,
requiredPermissions: [],
readOnly: false,
inputSchema: { ...restoreInput.schema, required: ["entity", "id"] },
async handler(context, args) {
const { key, entity, id } = prepareArchiveRestoreInput(args)
await authorize(context, restoreTool, entity, "write")
const table = entity.table
return context.server.db.transaction(async (tx) => {
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId))
const [existing] = await tx.select().from(table).where(scope).limit(1).for("update")
if (!existing) throw new Error(`${entity.label} nicht gefunden`)
if (!existing.archived) throw new Error(`${entity.label} ist nicht archiviert`)
const [result] = await tx.update(table).set({
archived: false,
[columnKey(table, "updatedAt", "updated_at")!]: new Date(),
[columnKey(table, "updatedBy", "updated_by")!]: context.userId,
}).where(scope).returning()
if (["products", "services"].includes(entity.resource)) {
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
}
await insertHistoryItem(server, {
tenant_id: context.tenantId, created_by: context.userId, entity: entity.resource, entityId: result.id,
action: "restored", oldVal: existing, newVal: result,
})
return { entity: key, id: result.id, success: true, action: "restored", record: withoutArchivedFlag(result) }
})
},
}
export const archiveTools: McpTool[] = [listTool, restoreTool]

View File

@@ -1,166 +0,0 @@
import { and, desc, eq, ilike, inArray, isNull, lt, or, sql } from "drizzle-orm"
import { authProfiles, customers, events, inventoryitemgroups, inventoryitems, projects, tenants, vehicles, vendors } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
import { arrayField, booleanField, dateField, idField, InputField, nullable, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
export const eventStates = ["Entwurf", "Final"]
export const eventRepeatIntervals = ["Keine Wiederholung", "Täglich", "Wöchentlich", "2-wöchentlich", "Monatlich", "Jährlich"]
const enumField = (values: string[]): InputField => ({
schema: { type: "string", enum: values },
parse(value, path) {
if (typeof value !== "string" || !values.includes(value)) throw new Error(`${path}: ungültiger Wert`)
return value
},
})
export const eventDateTimeField: InputField = {
schema: { type: "string", format: "date-time", description: "ISO-Zeitpunkt mit Zeitzone, z. B. 2026-10-15T09:00:00+02:00." },
parse(value, path) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
throw new Error(`${path}: ISO-Zeitpunkt mit Zeitzone erwartet`)
}
dateField.parse(value.slice(0, 10), path)
if (Number(value.slice(11, 13)) > 23 || Number(value.slice(14, 16)) > 59 || Number(value.slice(17, 19)) > 59
|| !Number.isFinite(Date.parse(value))) throw new Error(`${path}: ungültiger Zeitpunkt`)
return new Date(value)
},
}
const idsField = (field: InputField): InputField => ({
schema: { ...arrayField(field).schema, maxItems: 1000, uniqueItems: true },
parse(value, path) {
if (!Array.isArray(value) || value.length > 1000) throw new Error(`${path}: maximal 1000 IDs erwartet`)
return [...new Set(arrayField(field).parse(value, path))]
},
})
const eventFields = {
name: textField(true), startDate: eventDateTimeField, endDate: nullable(eventDateTimeField),
eventtype: nullable(textField(true)), quick: booleanField, state: enumField(eventStates), color: nullable(textField()),
repeatInterval: enumField(eventRepeatIntervals), notes: nullable(textField()), link: nullable(textField()),
project: nullable(idField), customer: nullable(idField), vendor: nullable(idField),
profiles: idsField(uuidField), vehicles: idsField(idField), inventoryitems: idsField(idField), inventoryitemgroups: idsField(uuidField),
}
const listFields = {
query: textField(), project: idField, customer: idField, vendor: idField, eventtype: textField(true),
from: eventDateTimeField, to: eventDateTimeField, state: enumField(eventStates), quick: booleanField,
repeatInterval: enumField(eventRepeatIntervals), profile: uuidField, vehicle: idField, inventoryitem: idField, inventoryitemgroup: uuidField,
includeRecurring: { ...booleanField, schema: { type: "boolean", default: true, description: "Bei from auch früher begonnene Wiederholungsserien liefern. Das Ergebnis enthält Serien-Stammsätze, keine einzelnen Vorkommen." } },
limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) },
offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) },
}
const eventScope = (tenantId: number, id: number) => and(eq(events.id, id), eq(events.tenant, tenantId), eq(events.archived, false))
export function prepareEventInput(action: "create" | "update" | "archive", args: Record<string, unknown>) {
const input = objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).parse(args, "arguments")
for (const key of action === "create" ? ["name", "startDate"] : ["id"]) {
if (input[key] === undefined) throw new Error(`${key} ist erforderlich`)
}
if (action === "update" && Object.keys(input).length === 1) throw new Error("Mindestens ein bearbeitbares Feld ist erforderlich")
return input
}
async function validateEventReferences(db: any, tenantId: number, input: Record<string, any>) {
const references = [
["project", projects], ["customer", customers], ["vendor", vendors], ["profiles", authProfiles],
["vehicles", vehicles], ["inventoryitems", inventoryitems], ["inventoryitemgroups", inventoryitemgroups],
] as const
for (const [field, table] of references) {
const value = input[field]
const ids = value == null ? [] : Array.isArray(value) ? value : [value]
if (!ids.length) continue
const scope = table === authProfiles
? and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.customer_for_portal))
: and(eq((table as any).tenant, tenantId), eq((table as any).archived, false))
const found = await db.select({ id: table.id }).from(table).where(and(inArray(table.id, ids), scope)).for("share")
if (found.length !== ids.length) throw new Error(`${field}: verknüpfter Datensatz im aktiven Mandanten nicht verfügbar`)
}
}
async function mutateEvent(context: McpContext, action: "create" | "update" | "archive", args: Record<string, unknown>) {
const { id, ...input } = prepareEventInput(action, args)
return context.server.db.transaction(async tx => {
let existing: typeof events.$inferSelect | undefined
if (action !== "create") {
;[existing] = await tx.select().from(events).where(eventScope(context.tenantId, id)).limit(1).for("update")
if (!existing) throw new Error("Termin nicht gefunden oder archiviert")
}
const merged = { ...existing, ...input }
if (action !== "archive") {
if (merged.endDate && new Date(merged.endDate).getTime() < new Date(merged.startDate).getTime()) {
throw new Error("endDate darf nicht vor startDate liegen")
}
await validateEventReferences(tx, context.tenantId, input)
}
const change = { ...(action === "archive" ? { archived: true } : input), updatedAt: new Date(), updatedBy: context.userId }
const [event] = action === "create"
? await tx.insert(events).values({ ...change, tenant: context.tenantId, name: input.name, startDate: input.startDate }).returning()
: await tx.update(events).set(change).where(eventScope(context.tenantId, id)).returning()
await insertHistoryItem({ ...context.server, db: tx } as any, { tenant_id: context.tenantId, created_by: context.userId,
entity: "events", entityId: event.id, action: action === "create" ? "created" : action === "archive" ? "archived" : "updated",
oldVal: existing, newVal: event })
return action === "archive" ? { id: event.id, success: true, action: "archived" } : { event }
})
}
export const calendarTools: McpTool[] = [
{
name: "organisation.calendar.options", title: "Kalenderoptionen laden",
description: "Lädt die konfigurierten Terminarten des Mandanten sowie erlaubte Status und Wiederholungen.",
requiredPermissions: ["organisation.events.read"], inputSchema: objectField({}).schema,
async handler(context, args) {
objectField({}).parse(args, "arguments")
const [tenant] = await context.server.db.select({ calendarConfig: tenants.calendarConfig }).from(tenants).where(eq(tenants.id, context.tenantId)).limit(1)
if (!tenant) throw new Error("Mandant nicht gefunden")
return { calendarConfig: tenant.calendarConfig, states: eventStates, repeatIntervals: eventRepeatIntervals }
},
},
{
name: "organisation.events.list", title: "Kalendertermine auflisten",
description: "Listet aktive Termine und Serien-Stammsätze mit Text-, Zeitraum-, Status- und Ressourcenfiltern. from/to begrenzen Überlappungen (to exklusiv); früher begonnene Serien werden standardmäßig mitgeliefert, ohne einzelne Wiederholungen auszurechnen.",
requiredPermissions: ["organisation.events.read"], inputSchema: objectField(listFields).schema,
async handler(context, args) {
const input = objectField(listFields).parse(args, "arguments")
if (input.from && input.to && input.from >= input.to) throw new Error("from muss vor to liegen")
const conditions = [eq(events.tenant, context.tenantId), eq(events.archived, false)]
if (input.query) {
const query = `%${input.query.replace(/[\\%_]/g, "\\$&")}%`
conditions.push(or(ilike(events.name, query), ilike(events.notes, query), ilike(events.link, query)))
}
for (const key of ["project", "customer", "vendor", "eventtype", "state", "quick", "repeatInterval"]) {
if (input[key] !== undefined) conditions.push(eq(events[key], input[key]))
}
for (const [key, column] of [["profile", events.profiles], ["vehicle", events.vehicles], ["inventoryitem", events.inventoryitems], ["inventoryitemgroup", events.inventoryitemgroups]] as const) {
if (input[key] !== undefined) conditions.push(sql`${column} @> ${JSON.stringify([input[key]])}::jsonb`)
}
if (input.to) conditions.push(lt(events.startDate, input.to))
if (input.from) conditions.push(or(
sql`${events.endDate} > ${input.from.toISOString()}::timestamptz`,
sql`${events.startDate} >= ${input.from.toISOString()}::timestamptz`,
input.includeRecurring !== false ? inArray(events.repeatInterval, eventRepeatIntervals.slice(1)) : undefined,
))
const rows = await context.server.db.select().from(events).where(and(...conditions)).orderBy(desc(events.startDate), events.id)
.limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
return paginatedResult(rows, input)
},
},
{
name: "organisation.events.get", title: "Kalendertermin laden", description: "Lädt einen aktiven Termin einschließlich Wiederholung und Ressourcenzuordnungen.",
requiredPermissions: ["organisation.events.read"], inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
async handler(context, args) {
const input = prepareEventInput("archive", args)
const [event] = await context.server.db.select().from(events).where(eventScope(context.tenantId, input.id)).limit(1)
if (!event) throw new Error("Termin nicht gefunden oder archiviert")
return { event }
},
},
...(["create", "update", "archive"] as const).map(action => ({
name: `organisation.events.${action}`, title: action === "create" ? "Kalendertermin erstellen" : action === "update" ? "Kalendertermin bearbeiten" : "Kalendertermin archivieren",
description: action === "create" ? "Erstellt einen Termin, standardmäßig Final und ohne Wiederholung. Zeitpunkte benötigen eine explizite Zeitzone."
: action === "update" ? "Ändert nur angegebene Felder. Ressourcenlisten werden ersetzt; null leert optionale Felder. Bei Wiederholungen wird die gesamte Serie geändert."
: "Archiviert einen Termin bzw. seine gesamte Wiederholungsserie.",
requiredPermissions: ["organisation.events.write"],
inputSchema: { ...objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).schema,
required: action === "create" ? ["name", "startDate"] : ["id"] },
handler: (context: McpContext, args: Record<string, unknown>) => mutateEvent(context, action, args),
})),
]

View File

@@ -1,148 +0,0 @@
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"
import { files, filetags, folders, scannerInboxDocuments } from "../../../db/schema"
import { assignmentKinds, assignmentOptions, assignmentTargets, FileAssignments } from "../../modules/scanner-inbox-assignments"
import { createScannerInboxService, ScannerInboxService } from "../../modules/scanner-inbox.service"
import { loadFileBuffer } from "../../utils/fileBuffer"
import { assertInternalDocumentAccess, decodeDocumentUpload, documentDownloadUrl, fileMetadataColumns, loadDocumentFile, MCP_FILE_LIMIT } from "../documentFiles"
import { InputField, idField, nullable, numericField, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
const searchField: InputField = {
schema: { type: "string", maxLength: 200, description: "Suchtext im Dateinamen bzw. Namen." },
parse(value, path) {
const text = textField().parse(value, path)
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
return text
},
}
const filenameField: InputField = {
schema: { type: "string", minLength: 1, maxLength: 200 },
parse: (value, path) => textField(true).parse(value, path),
}
const paginationFields = {
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
}
const assignmentFields = Object.fromEntries(Object.entries(assignmentTargets).map(([key, target]) =>
[key, nullable(target.uuid ? uuidField : idField)]))
const assignmentsField = objectField(assignmentFields)
const kindField: InputField = {
schema: { type: "string", enum: Object.keys(assignmentTargets) },
parse(value) {
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(assignmentTargets, value)) throw new Error("Unbekannte Zuordnungsart.")
return value
},
}
const formatField: InputField = {
schema: { type: "string", enum: ["url", "base64"], default: "url" },
parse(value) {
if (value !== "url" && value !== "base64") throw new Error("format muss url oder base64 sein.")
return value
},
}
const like = (value: string) => `%${value.replace(/[\\%_]/g, "\\$&")}%`
type Dependencies = {
inbox?: (context: McpContext) => ScannerInboxService
readFile?: typeof loadFileBuffer
}
export function createDocumentTools(dependencies: Dependencies = {}): McpTool[] {
const inbox = dependencies.inbox || ((context: McpContext) => createScannerInboxService(context.server.db))
const readFile = dependencies.readFile || loadFileBuffer
const tool = (name: string, title: string, description: string, write: boolean, fields: Record<string, InputField>,
required: string[], handler: McpTool["handler"]): McpTool => ({
name: `documents.${name}`, title, description,
requiredPermissions: [write ? "documents.write" : "documents.read"],
inputSchema: { ...objectField(fields).schema, required },
async handler(context, args) {
const input = objectField(fields).parse(args, "arguments")
for (const key of required) if (input[key] === undefined) throw new Error(`${key} ist erforderlich.`)
await assertInternalDocumentAccess(context)
return handler(context, input)
},
})
return [
tool("inbox.list", "Dokumentenablage auflisten", "Listet offene Dokumente der Dokumentenablage mit Status der KI-Ablagevorschläge. id ist die Datei-UUID.", false,
{ search: searchField, ...paginationFields }, [], async (context, args) => {
const rows = await context.server.db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt,
suggestionStatus: scannerInboxDocuments.suggestionStatus })
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
.where(and(eq(scannerInboxDocuments.tenantId, context.tenantId), eq(files.tenant, context.tenantId),
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
args.search ? ilike(scannerInboxDocuments.remoteFilename, like(args.search as string)) : undefined))
.orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
}),
tool("inbox.count", "Offene Dokumente zählen", "Zählt nicht archivierte, noch nicht abgelegte Dokumente des Mandanten.", false, {}, [],
context => inbox(context).openCount(context.tenantId)),
...([ ["folders", folders], ["types", filetags] ] as const).map(([name, table]) =>
tool(`${name}.list`, name === "folders" ? "Ablageordner auflisten" : "Dokumenttypen auflisten",
"Listet verfügbare Ablageziele. Ordner enthalten parent, standardFiletype und standardFiletypeIsOptional.", false,
{ search: searchField, ...paginationFields }, [], async (context, args) => {
const rows = await context.server.db.select().from(table)
.where(and(eq(table.tenant, context.tenantId), eq(table.archived, false), args.search ? ilike(table.name, like(args.search as string)) : undefined))
.orderBy(asc(table.name), asc(table.id)).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
})),
tool("assignment_kinds.list", "Zuordnungsarten auflisten", "Zeigt die unterstützten Zuordnungsarten, beispielsweise Projekt, Kunde, Eingangsrechnung oder Ausgangsbeleg.", false, {}, [],
async () => ({ kinds: assignmentKinds })),
tool("assignments.search", "Zuordnungsziele suchen", "Sucht verfügbare Datensätze einer Zuordnungsart im aktiven Mandanten.", false,
{ kind: kindField, search: searchField, ...paginationFields }, ["kind"], async (context, args) =>
paginatedResult(await assignmentOptions(context.server.db, context.tenantId, args.kind as string, args.search as string || "",
limitFromArgs(args) + 1, offsetFromArgs(args)), args)),
tool("inbox.assignments.get", "Dokumentzuordnungen laden", "Lädt bestehende Zuordnungen eines offenen Dokuments aus der Dokumentenablage.", false,
{ id: uuidField }, ["id"], async (context, args) => ({ assignments: await inbox(context).getAssignments(context.tenantId, args.id as string) })),
tool("inbox.suggestion.get", "Ablagevorschlag laden", "Lädt Status und Vorschlag der KI-Erkennung. Ein Vorschlag legt das Dokument noch nicht ab.", false,
{ id: uuidField }, ["id"], (context, args) => inbox(context).getSuggestion(context.tenantId, args.id as string)),
tool("inbox.suggestion.refresh", "Ablagevorschlag neu anfordern", "Stellt die KI-Erkennung erneut in die Warteschlange. Den Status anschließend mit documents.inbox.suggestion.get abfragen.", true,
{ id: uuidField }, ["id"], (context, args) => inbox(context).queueSuggestion(context.tenantId, args.id as string)),
tool("inbox.file", "Dokument ablegen", "Legt ein offenes Dokument in einem Ordner (folder: null für Hauptebene) mit Dokumenttyp ab. Optionale Zuordnungen ändern nur angegebene Felder; null entfernt eine Zuordnung. Feste Ordnertypen werden geprüft.", true,
{ id: uuidField, folder: nullable(uuidField), type: uuidField, assignments: assignmentsField }, ["id", "folder", "type"],
(context, args) => inbox(context).fileDocument(context.tenantId, context.userId, args.id as string, args.folder as string | null,
args.type as string, args.assignments as FileAssignments)),
tool("inbox.archive", "Dokument archivieren", "Archiviert ein offenes Dokument aus der Dokumentenablage. Bereits abgelegte Dokumente werden abgewiesen.", true,
{ id: uuidField }, ["id"], async (context, args) => {
await inbox(context).archiveDocument(context.tenantId, context.userId, args.id as string)
return { id: args.id, success: true, action: "archived" }
}),
tool("files.list", "Gespeicherte Dateien suchen", "Listet nicht archivierte Dateien einschließlich PDFs zu Eingangsrechnungen und Ausgangsbelegen. Filter werden kombiniert; folder: null sucht auf der Hauptebene.", false,
{ search: searchField, folder: nullable(uuidField), type: uuidField, mimeType: textField(true), ...assignmentFields, ...paginationFields }, [],
async (context, args) => {
const conditions = [eq(files.tenant, context.tenantId), eq(files.archived, false)]
if (args.search) conditions.push(or(ilike(files.name, like(args.search as string)), ilike(files.extractedText, like(args.search as string))))
for (const key of ["folder", "type", "mimeType", ...Object.keys(assignmentFields)]) {
if (args[key] !== undefined) conditions.push(args[key] === null ? isNull(files[key]) : eq(files[key], args[key]))
}
const rows = await context.server.db.select(fileMetadataColumns).from(files).where(and(...conditions))
.orderBy(desc(files.createdAt), files.id).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
}),
tool("files.get", "Dateidetails laden", "Lädt Metadaten, Zuordnungs-IDs und bereits extrahierten Text einer Datei. Binärinhalt über documents.files.download abrufen.", false,
{ id: uuidField }, ["id"], async (context, args) => {
const { path, ...file } = await loadDocumentFile(context, args.id as string)
return { file }
}),
tool("files.download", "PDF oder Datei herunterladen", "Liefert standardmäßig eine Download-URL für die Originaldatei. GET benötigt denselben Authorization: Bearer MCP-Token. Alternativ format: base64 für Dateiinhalt bis 20 MiB direkt im Ergebnis; keine PDF-Konvertierung.", false,
{ id: uuidField, format: formatField }, ["id"], async (context, args) => {
const file = await loadDocumentFile(context, args.id as string)
if (!file.path) throw new Error("Kein Dateiinhalt verfügbar.")
const metadata = { id: file.id, filename: file.name, mimeType: file.mimeType, size: file.size }
if (args.format !== "base64") return { ...metadata, url: documentDownloadUrl(file.id), method: "GET", authentication: "Bearer" }
if (file.size > MCP_FILE_LIMIT) throw new Error("Die Datei ist für Base64 zu groß. Bitte format: url verwenden.")
const body = await readFile(file.path, MCP_FILE_LIMIT)
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
}),
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url hochladen. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
["filename", "contentBase64"], async (context, args) => {
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
const result = await inbox(context).upload(context.tenantId, context.userId, args.filename as string, body)
return { ...result, filename: args.filename, mimeType, size: body.length }
}),
]
}
export const documentTools = createDocumentTools()

View File

@@ -1,10 +1,9 @@
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm"
import { customers, plants, projects, tasks } from "../../../db/schema"
import { customers, events, plants, projects, tasks } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
import { activateProjectPhase } from "../projectPhases"
import { McpTool } from "../types"
import { calendarTools } from "./calendar"
const stringArg = (args: Record<string, unknown>, key: string) => {
@@ -18,7 +17,6 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
}
export const organisationTools: McpTool[] = [
...calendarTools,
{
name: "organisation.customers.search",
title: "Kunden suchen",
@@ -240,6 +238,51 @@ export const organisationTools: McpTool[] = [
return paginatedResult(rows, args)
},
},
{
name: "organisation.events.list",
title: "Termine auflisten",
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
requiredPermissions: ["organisation.events.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
project: { type: "number" },
customer: { type: "number" },
eventtype: { type: "string" },
...paginationProperties,
},
},
async handler(context, args) {
const conditions = [eq(events.tenant, context.tenantId)]
const query = stringArg(args, "query")
const project = numberArg(args, "project")
const customer = numberArg(args, "customer")
const eventtype = stringArg(args, "eventtype")
if (query) {
conditions.push(or(
ilike(events.name, `%${query}%`),
ilike(events.notes, `%${query}%`),
ilike(events.link, `%${query}%`)
))
}
if (project) conditions.push(eq(events.project, project))
if (customer) conditions.push(eq(events.customer, customer))
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
conditions.push(eq(events.archived, false))
const rows = await context.server.db
.select()
.from(events)
.where(and(...conditions))
.orderBy(desc(events.startDate), events.id)
.limit(limitFromArgs(args) + 1)
.offset(offsetFromArgs(args))
return paginatedResult(rows, args)
},
},
{
name: "organisation.tasks.list",
title: "Aufgaben auflisten",

View File

@@ -1,130 +0,0 @@
import { and, desc, eq, inArray, ne } from "drizzle-orm"
import { createddocuments, statementallocations } from "../../../db/schema"
import { booleanField, idField, InputField, objectField, textField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
const paymentStates = ["Offen", "Teilbezahlt", "Bezahlt", "Überbezahlt"]
const paymentField: InputField = {
schema: { type: "string", enum: paymentStates },
parse(value) {
if (typeof value !== "string" || !paymentStates.includes(value)) throw new Error("Unbekannter Zahlungsstatus")
return value
},
}
const listFields = {
state: textField(true), type: textField(true), customer: idField, project: idField,
cancelled: booleanField, paymentStatus: paymentField,
limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) },
offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) },
}
const money = (value: number) => Number(value.toFixed(2))
function documentAmount(document: any, advanceInvoices: any[]) {
let net = 0, tax19 = 0, tax7 = 0, advanceAmount = 0
for (const row of document.rows || []) {
if (["pagebreak", "title", "text"].includes(row.mode)) continue
const values = [row.quantity, row.price, row.discountPercent, row.taxPercent].map(value => Number(value ?? 0))
if (!values.every(Number.isFinite)) throw new Error(`Beleg ${document.id}: ungültiger Positionsbetrag`)
const [quantity, price, discount, tax] = values
const rowNet = Number((quantity * price * (1 - discount / 100)).toFixed(3))
net += rowNet
if (tax === 19) tax19 += rowNet * 0.19
if (tax === 7) tax7 += rowNet * 0.07
}
for (const id of document.usedAdvanceInvoices || []) {
const row = advanceInvoices.find(invoice => invoice.id === id)?.rows?.find(row => row.advanceInvoiceData)
if (row) advanceAmount += Number(row.price) * ((100 + Number(row.taxPercent)) / 100)
}
// Match the outgoing-document screen (useSum): round net, 19% and 7% separately.
// Aggregating both tax buckets before rounding can falsely report a one-cent debt.
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
}
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[]) {
const amount = documentAmount(document, advanceInvoices)
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
const openAmount = money(amount - paidAmount)
const cancelled = cancellations.length > 0
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
// Signed invoices and refunds use the same payment-direction comparison.
const direction = Math.sign(amount) || 1
const paymentStatus = !applicable ? null : openAmount === 0 ? "Bezahlt"
: openAmount * direction < 0 ? "Überbezahlt" : paidAmount * direction > 0 ? "Teilbezahlt" : "Offen"
return {
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
state: document.state, customer: document.customer, project: document.project,
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
}
}
async function statusesFor(context: McpContext, documents: Array<typeof createddocuments.$inferSelect>) {
if (!documents.length) return []
const ids = documents.map(document => document.id)
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
const [allocations, cancellations, advances] = await Promise.all([
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount }).from(statementallocations)
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
ne(createddocuments.state, "Entwurf"), inArray(createddocuments.createddocument, ids))).orderBy(createddocuments.id),
advanceIds.length ? context.server.db.select().from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
])
return documents.map(document => outgoingDocumentStatus(document,
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
}
export const outgoingStatusTools: McpTool[] = [
{
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
async handler(context, args) {
const { id } = objectField({ id: idField }).parse(args, "arguments")
if (id === undefined) throw new Error("id ist erforderlich")
const documents = await context.server.db.select().from(createddocuments)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false))).limit(1)
if (!documents.length) throw new Error("Ausgangsbeleg nicht gefunden oder archiviert")
const [status] = await statusesFor(context, documents)
return { status }
},
},
{
name: "accounting.outgoing_documents.status.list", title: "Ausgangsbelege nach Status filtern",
description: "Listet Status und Beträge aktiver Ausgangsbelege. Kombinierbare Filter: state (z. B. Entwurf/Gebucht), paymentStatus, cancelled, type, customer und project. Statusfilter werden vor der Pagination angewendet. Archivierte Zahlungen und Stornos zählen nicht.",
requiredPermissions: ["accounting.outgoing_documents.read"], inputSchema: objectField(listFields).schema,
async handler(context, args) {
const input = objectField(listFields).parse(args, "arguments")
const conditions = [eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)]
for (const key of ["state", "type", "customer", "project"]) {
if (input[key] !== undefined) conditions.push(eq(createddocuments[key], input[key]))
}
const limit = limitFromArgs(input)
const offset = offsetFromArgs(input)
const query = (size: number, start: number) => context.server.db.select().from(createddocuments).where(and(...conditions))
.orderBy(desc(createddocuments.createdAt), createddocuments.id).limit(size).offset(start)
if (input.paymentStatus === undefined && input.cancelled === undefined) {
return paginatedResult(await statusesFor(context, await query(limit + 1, offset)), input)
}
// Derived statuses need related payments/credit documents. Scan bounded batches so filtering
// happens before pagination without loading an entire tenant's documents into memory.
const rows: Awaited<ReturnType<typeof statusesFor>> = []
let matched = 0
for (let start = 0; ; start += 100) {
const documents = await query(100, start)
for (const status of await statusesFor(context, documents)) {
if (input.paymentStatus !== undefined && status.paymentStatus !== input.paymentStatus) continue
if (input.cancelled !== undefined && status.cancelled !== input.cancelled) continue
if (matched++ >= offset) rows.push(status)
if (rows.length > limit) return paginatedResult(rows, input)
}
if (documents.length < 100) return paginatedResult(rows, input)
}
},
},
]

View File

@@ -23,8 +23,6 @@ export type McpTool = {
title: string
description: string
requiredPermissions: string[]
/** Overrides the readOnlyHint derived from requiredPermissions, e.g. for per-entity checks. */
readOnly?: boolean
inputSchema: Record<string, unknown>
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
}

View File

@@ -1,71 +0,0 @@
import { FastifyInstance } from "fastify"
import { and, eq, sql } from "drizzle-orm"
import { bankaccounts, bankstatements } from "../../db/schema"
export const CASHBOOK_BANK_ID = "fedeo-cashbook"
type BalanceAccount = Pick<typeof bankaccounts.$inferSelect, "id" | "name" | "iban" | "ownerName" | "bankId" | "datevNumber" | "balance" | "expired" | "syncedAt">
const roundMoney = (value: number) => Math.round(value * 100) / 100
/**
* Bank accounts store the last synced booked balance; cashbooks store their opening balance
* and derive the current balance from their bookings. For a past date, bank balances are
* reconstructed by reverting the statements booked after that date.
*/
export function calculateAccountBalance(account: BalanceAccount, statementSum: number, asOf: string | null) {
const cashbook = account.bankId === CASHBOOK_BANK_ID
const stored = Number(account.balance || 0)
const balance = cashbook ? stored + statementSum : stored - statementSum
return {
id: account.id,
type: cashbook ? "cashbook" : "bank",
name: account.name || account.ownerName || (cashbook ? "Kassenbuch" : "Bankkonto"),
iban: cashbook ? null : account.iban,
datevNumber: account.datevNumber,
expired: account.expired,
balance: roundMoney(balance),
currency: "EUR",
...(cashbook ? { openingBalance: roundMoney(stored) } : { syncedBalance: roundMoney(stored), syncedAt: account.syncedAt }),
source: cashbook ? "cashbook_bookings" : asOf ? "bank_sync_reconstructed" : "bank_sync",
}
}
export async function loadBankBalances(server: FastifyInstance, tenantId: number, options: { account?: number | null; asOf?: string | null } = {}) {
const asOf = options.asOf || null
const conditions = [eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false)]
if (options.account) conditions.push(eq(bankaccounts.id, options.account))
const accounts = await server.db.select().from(bankaccounts).where(and(...conditions)).orderBy(bankaccounts.name, bankaccounts.id)
const sums = async (dateCondition: any) => {
const rows = await server.db
.select({ account: bankstatements.account, total: sql<string>`coalesce(sum(${bankstatements.amount}), 0)` })
.from(bankstatements)
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), ...(dateCondition ? [dateCondition] : [])))
.groupBy(bankstatements.account)
return new Map(rows.map((row) => [row.account, Number(row.total)]))
}
// Synced statements may carry a full timestamp; compare the calendar day only.
const bookingDay = sql`left(${bankstatements.date}, 10)`
// Cashbooks need bookings up to the date, bank accounts only those after it.
const cashbookSums = await sums(asOf ? sql`${bookingDay} <= ${asOf}` : null)
const bankSums = asOf ? await sums(sql`${bookingDay} > ${asOf}`) : new Map<number, number>()
const balances = accounts.map((account) => calculateAccountBalance(
account,
(account.bankId === CASHBOOK_BANK_ID ? cashbookSums : bankSums).get(account.id) || 0,
asOf,
))
const active = balances.filter((account) => !account.expired)
return {
asOf,
accounts: balances,
totals: {
balance: roundMoney(active.reduce((sum, account) => sum + account.balance, 0)),
bank: roundMoney(active.filter((account) => account.type === "bank").reduce((sum, account) => sum + account.balance, 0)),
cashbook: roundMoney(active.filter((account) => account.type === "cashbook").reduce((sum, account) => sum + account.balance, 0)),
currency: "EUR",
note: "Summen ohne abgelaufene Bankverbindungen.",
},
}
}

View File

@@ -57,13 +57,10 @@ const adminPermissions = [
"organisation.projects.write",
"organisation.plants.read",
"organisation.events.read",
"organisation.events.write",
"organisation.tasks.read",
"organisation.tasks.write",
"wiki.read",
"wiki.write",
"documents.read",
"documents.write",
]
const defaultTaxTypes = [

View File

@@ -1,90 +0,0 @@
import sharp from "sharp"
import { sql } from "drizzle-orm"
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
import { fail, text, uuid } from "./collaboration/types"
export const reactionEmojis = ["👍", "❤️", "😂", "🎉", "😮", "😢", "🙏", "🔥", "✅", "👎"]
export type ChatImage = { dataUrl: string; name: string; width: number; height: number }
type Db = Pick<NodePgDatabase<any>, "execute">
type Source = "communication" | "collaboration"
/** Only re-encoded raster images are stored; neither URLs nor original metadata are retained. */
export async function chatContent(input: any): Promise<{ body: string; image: ChatImage | null }> {
const body = text(input?.text, 10000, true)
let image: ChatImage | null = null
if (input?.image != null) {
const value = input.image
if (typeof value.dataUrl !== "string" || value.dataUrl.length > 700_000) fail("Das Bild ist zu groß (maximal 512 KB nach Verkleinerung)")
const match = /^data:image\/(?:jpeg|png|webp);base64,([A-Za-z0-9+/]+={0,2})$/.exec(value.dataUrl)
if (!match) fail("Nur JPEG-, PNG- und WebP-Bilder sind erlaubt")
try {
const buffer = Buffer.from(match[1], "base64")
if (buffer.length > 512 * 1024) fail("Das Bild ist zu groß")
const decoder = sharp(buffer, { limitInputPixels: 16_000_000, failOn: "warning" })
const metadata = await decoder.metadata()
if (!["jpeg", "png", "webp"].includes(metadata.format || "") || (metadata.pages || 1) > 1) fail("Bitte ein einzelnes JPEG-, PNG- oder WebP-Bild verwenden")
const result = await decoder.rotate().resize({ width: 1600, height: 1600, fit: "inside", withoutEnlargement: true })
.webp({ quality: 82 }).toBuffer({ resolveWithObject: true })
if (result.data.length > 512 * 1024) fail("Das Bild ist zu groß. Bitte eine kleinere Datei wählen")
image = { dataUrl: `data:image/webp;base64,${result.data.toString("base64")}`,
name: text(value.name || "Bild", 200), width: result.info.width, height: result.info.height }
} catch (error: any) {
if (error.statusCode) throw error
fail("Das Bild konnte nicht gelesen werden")
}
}
if (!body && !image) fail("Nachricht darf nicht leer sein")
return { body, image }
}
export function imageMetadata(value: any) {
if (value == null) return null
if (!Number.isInteger(value.width) || !Number.isInteger(value.height) || value.width < 1 || value.height < 1
|| value.width > 1600 || value.height > 1600) fail("Ungültige Bildabmessungen")
return { name: text(value.name, 200), width: value.width, height: value.height }
}
function messageId(source: Source, value: unknown) {
if (source === "collaboration") return uuid(value)
const id = Number(value)
if (!Number.isSafeInteger(id) || id < 1) fail("Ungültige Nachricht")
return id
}
const tables = (source: Source) => ({ messages: sql.identifier(`${source}_messages`), reactions: sql.identifier(`${source}_message_reactions`) })
const history = (source: Source, from: number) => source === "collaboration" ? sql`AND m.seq>=${from}` : sql``
/** Callers must authorize room membership first, including the current history boundary. */
export async function chatImage(db: Db, source: Source, roomId: string, id: unknown, from = 0) {
const { messages } = tables(source)
const result = await db.execute(sql`SELECT m.image FROM ${messages} m WHERE m.room_id=${roomId}
AND m.id=${messageId(source, id)} ${history(source, from)}`)
const image = result.rows[0]?.image as ChatImage | null
if (!image) fail("Bild nicht gefunden oder kein Zugriff", 404)
return { dataUrl: image.dataUrl }
}
export async function chatReactions(db: Db, source: Source, roomId: string, actorId: string, value: unknown, from = 0) {
const ids = typeof value === "string" ? value.split(",").filter(Boolean) : value
if (!Array.isArray(ids) || ids.length > 100) fail("Ungültige Nachrichtenliste")
const unique = [...new Set(ids.map(id => messageId(source, id)))]
const reactions: Record<string, { emoji: string; count: number; own: boolean }[]> = {}
if (!unique.length) return { reactions }
const { messages, reactions: table } = tables(source)
const result = await db.execute(sql`SELECT r.message_id AS id,r.emoji,count(*)::int AS count,bool_or(r.actor_id=${actorId}) AS own
FROM ${table} r JOIN ${messages} m ON m.id=r.message_id
WHERE m.room_id=${roomId} AND m.id IN (${sql.join(unique.map(id => sql`${id}`), sql`,`)}) ${history(source, from)}
GROUP BY r.message_id,r.emoji ORDER BY r.emoji`)
for (const row of result.rows as any[]) (reactions[row.id] ||= []).push({ emoji: row.emoji, count: row.count, own: row.own })
return { reactions }
}
export async function setChatReaction(db: Db, source: Source, roomId: string, actorId: string, input: any, from = 0) {
const id = messageId(source, input?.messageId)
if (!reactionEmojis.includes(input?.emoji) || typeof input?.active !== "boolean") fail("Ungültige Reaktion")
const { messages, reactions } = tables(source)
const result = await db.execute(sql`SELECT m.id FROM ${messages} m WHERE m.room_id=${roomId} AND m.id=${id} ${history(source, from)}`)
if (!result.rows.length) fail("Nachricht nicht gefunden oder kein Zugriff", 404)
if (input.active) await db.execute(sql`INSERT INTO ${reactions}(message_id,actor_id,emoji) VALUES (${id},${actorId},${input.emoji}) ON CONFLICT DO NOTHING`)
else await db.execute(sql`DELETE FROM ${reactions} WHERE message_id=${id} AND actor_id=${actorId} AND emoji=${input.emoji}`)
return chatReactions(db, source, roomId, actorId, [id], from)
}

View File

@@ -1,30 +0,0 @@
import { instanceUrl } from "./transport"
type Settings = {
API_BASE_URL?: string;
ENCRYPTION_KEY?: string;
FEDEO_PUBLIC_URL?: string;
FEDEO_FEDERATION_URL?: string;
FEDEO_FEDERATION_ENABLED?: string;
}
/** Existing deployment settings are sufficient; extra URLs are optional overrides. */
export function collaborationConfig(settings: Settings) {
const configuredApi = settings.API_BASE_URL?.trim() || ""
let apiUrl = (settings.FEDEO_FEDERATION_URL?.trim() || configuredApi).replace(/\/+$/, "")
let publicUrl = settings.FEDEO_PUBLIC_URL?.trim().replace(/\/+$/, "") || ""
if (!publicUrl) {
try { publicUrl = new URL(configuredApi || apiUrl).origin } catch { /* Local development may have no public URL. */ }
}
const mode = settings.FEDEO_FEDERATION_ENABLED?.trim().toLowerCase() || "auto"
if (!["auto", "true", "false"].includes(mode)) throw new Error("FEDEO_FEDERATION_ENABLED muss auto, true oder false sein")
if (mode === "false") return { apiUrl, publicUrl, enabled: false, disabledReason: "Die Föderation wurde vom Betreiber deaktiviert." }
let disabledReason: string | undefined
try { apiUrl = instanceUrl(apiUrl) }
catch { disabledReason = "Föderation benötigt eine gültige öffentliche HTTPS-API_BASE_URL auf Port 443." }
if (!disabledReason && !/^[a-f0-9]{64}$/i.test(settings.ENCRYPTION_KEY || "")) {
disabledReason = "Föderation benötigt einen gültigen ENCRYPTION_KEY mit 64 Hex-Zeichen." }
if (mode === "true" && disabledReason) throw new Error(disabledReason)
return { apiUrl, publicUrl, enabled: !disabledReason, disabledReason }
}

View File

@@ -1,4 +1,3 @@
import { chatContent, imageMetadata } from "../chat-content"
import { generateKeyPairSync, randomUUID } from "node:crypto"
import { sql } from "drizzle-orm"
import type { FastifyInstance } from "fastify"
@@ -8,7 +7,6 @@ import { FederationKey, Headers, instanceUrl, safeTransport, signedHeaders, Tran
type Options = {
apiUrl: string; enabled: boolean;
disabledReason?: string;
seal: (value: string) => any; unseal: (value: any) => string;
transport?: Transport;
}
@@ -24,7 +22,7 @@ export class FederationService {
this.transport = options.transport || safeTransport
}
enabled() {
if (!this.options.enabled) fail(this.options.disabledReason || "Der Betreiber muss die Föderation zuerst aktivieren", 503)
if (!this.options.enabled) fail("Der Betreiber muss die Föderation zuerst aktivieren", 503)
instanceUrl(this.origin)
}
async keys() {
@@ -73,7 +71,7 @@ export class FederationService {
VALUES (${ctx.tenantId},${origin},${instanceId},${keyId},${publicKey}) ON CONFLICT(tenant_id,origin) DO UPDATE SET active=true,updated_at=now()`)
}
}
return { enabled: this.options.enabled, disabledReason: this.options.disabledReason, origin: this.origin, partners: (await rows(this.service.db,
return { enabled: this.options.enabled, origin: this.origin, partners: (await rows(this.service.db,
sql`SELECT origin,active,public_key FROM collaboration_partners WHERE tenant_id=${ctx.tenantId} ORDER BY origin`)).map(p => ({ origin: p.origin, active: p.active, fingerprint: hash(p.public_key) })) }
}
async call(tenantId: number, origin: string, action: string, payload: any) {
@@ -123,9 +121,7 @@ export class FederationService {
ON CONFLICT(dedup_key) DO UPDATE SET dedup_key=excluded.dedup_key RETURNING *`)
if (JSON.stringify(job.payload) !== JSON.stringify(entry.payload)) {
// JSONB key order differs: compare the actual command values instead of its serialization.
if (job.payload.input?.text !== entry.payload.input?.text || job.payload.actorId !== entry.payload.actorId || job.payload.roomId !== entry.payload.roomId || job.payload.input?.revision !== entry.payload.input?.revision
|| (job.payload.input?.image?.dataUrl || null) !== (entry.payload.input?.image?.dataUrl || null)
|| (job.payload.input?.image?.name || null) !== (entry.payload.input?.image?.name || null)) fail("Auftrags-ID wurde bereits anders verwendet", 409)
if (job.payload.input?.text !== entry.payload.input?.text || job.payload.actorId !== entry.payload.actorId || job.payload.roomId !== entry.payload.roomId || job.payload.input?.revision !== entry.payload.input?.revision) fail("Auftrags-ID wurde bereits anders verwendet", 409)
}
return job
}
@@ -207,7 +203,6 @@ export class FederationService {
if (updated) {
await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${updated.id}`)
await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Neue Teilnahme erforderlich' WHERE binding_id=${updated.id} AND status='pending'`)
await this.service.options.notifications?.remoteInvitation(db, principal, updated.id, invitationId, snapshot.name)
}
})
return { received: true }
@@ -227,9 +222,6 @@ export class FederationService {
if (action === "room.get") return this.service.room(principal, room.id)
if (action === "messages.get") return this.service.messages(principal, room.id, request.input)
if (action === "messages.send") return this.service.send(principal, room.id, request.input)
if (action === "image.get") return this.service.image(principal, room.id, request.input)
if (action === "reactions.get") return this.service.reactions(principal, room.id, request.input)
if (action === "reactions.set") return this.service.react(principal, room.id, request.input)
if (action === "members.get") return { members: await this.service.members(principal, room.id) }
if (action === "read") return this.service.markRead(principal, room.id, request.input?.seq)
fail("Unbekannte Föderationsaktion")
@@ -242,11 +234,9 @@ export class FederationService {
for (const b of bindings) {
if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true })
if (b.status === "active") {
const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count,
(SELECT payload->>'createdAt' FROM collaboration_remote_messages WHERE binding_id=${b.id} ORDER BY seq DESC LIMIT 1) AS "lastActivityAt"
FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`)
const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`)
result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic,
ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count, lastActivityAt: unread.lastActivityAt || null })
ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count })
}
}
return result
@@ -309,7 +299,7 @@ export class FederationService {
}
async cache(binding: any, messages: any[], snapshot?: any) {
if (!Array.isArray(messages) || messages.length > 50) fail("Ungültige Nachrichtenantwort", 502)
const projected = messages.map(m => ({ id: uuid(m.id), clientId: uuid(m.clientId), seq: sequence(m.seq), body: text(m.body, 10000, Boolean(m.image)), image: imageMetadata(m.image),
const projected = messages.map(m => ({ id: uuid(m.id), clientId: uuid(m.clientId), seq: sequence(m.seq), body: text(m.body, 10000),
senderName: text(m.senderName, 400), senderKind: ['user','guest','remote'].includes(m.senderKind) ? m.senderKind : 'remote',
own: m.own === true, createdAt: new Date(m.createdAt).toISOString() }))
await this.service.db.transaction(async db => {
@@ -325,11 +315,6 @@ export class FederationService {
for (const message of projected.filter(m => m.seq >= from)) await db.execute(sql`INSERT INTO collaboration_remote_messages(binding_id,id,seq,payload)
VALUES (${binding.id},${message.id},${message.seq},${JSON.stringify(message)}::jsonb)
ON CONFLICT(binding_id,id) DO UPDATE SET payload=excluded.payload`)
if (snapshot) {
const unreadAfter = Math.max(Number(current.sync_seq), Number(current.last_read_seq), snapshot.lastReadSeq)
const incoming = projected.filter(m => !m.own && m.seq >= from && m.seq > unreadAfter)
await this.service.options.notifications?.remoteMessages(db, current, snapshot.name, incoming)
}
})
}
async memberRequest(ctx: UserContext, id: string, action: string, input: any = {}) {
@@ -340,9 +325,8 @@ export class FederationService {
await this.allowed(ctx.tenantId, binding.origin)
if (action === "send") {
if (!binding.snapshot?.canSend) fail("Unterraum ist nicht zum Schreiben freigegeben", 403)
const content = await chatContent(input)
const command = { actorId: ctx.principal.id, roomId: binding.room_id,
input: { clientId: uuid(input.clientId), text: content.body, image: content.image, revision: Number(binding.revision) } }
input: { clientId: uuid(input.clientId), text: text(input.text, 10000), revision: Number(binding.revision) } }
const job = await this.enqueue(this.service.db, { key: `message:${binding.id}:${command.input.clientId}`, ctx, origin: binding.origin,
action: 'messages.send', payload: command, bindingId: binding.id })
await this.deliver(job)
@@ -351,20 +335,6 @@ export class FederationService {
if (currentJob.status === 'failed') fail(currentJob.last_error || "Nachricht konnte nicht gesendet werden", 409)
return { found: true, result: message?.payload || this.pendingMessage(currentJob) }
}
if (["image", "reactions", "react"].includes(action)) {
const rpc = action === "image" ? "image.get" : action === "react" ? "reactions.set" : "reactions.get"
try {
const result = await this.call(ctx.tenantId, binding.origin, rpc, { actorId: ctx.principal.id, roomId: binding.room_id,
input: { ...input, revision: Number(binding.revision) } })
return { found: true, result }
} catch (error: any) {
if ([401, 403, 404, 410].includes(error.statusCode)) {
// A missing image/message is not by itself evidence of revoked room access.
await this.sync(ctx, binding)
}
throw error
}
}
const detail = await this.sync(ctx, binding)
binding = await this.binding(ctx, id)
if (action === "room") return { found: true, result: detail }
@@ -397,7 +367,7 @@ export class FederationService {
return { found: true, result: { messages, hasMore: page.length === 50, offline: detail.offline } }
}
pendingMessage(job: any) {
return { id: job.payload.input.clientId, clientId: job.payload.input.clientId, seq: null, body: job.payload.input.text, image: imageMetadata(job.payload.input.image),
return { id: job.payload.input.clientId, clientId: job.payload.input.clientId, seq: null, body: job.payload.input.text,
createdAt: job.created_at, own: true, senderName: 'Du', senderKind: 'user', pending: job.status === 'pending', failed: job.status === 'failed' }
}
async deliver(job: any) {
@@ -462,12 +432,12 @@ export class FederationService {
export async function federationRoutes(server: FastifyInstance, { federation }: { federation: FederationService }) {
server.removeContentTypeParser('application/json')
server.addContentTypeParser('application/json', { parseAs: 'string', bodyLimit: 1024 * 1024 }, (req: any, body: string, done) => {
server.addContentTypeParser('application/json', { parseAs: 'string', bodyLimit: 128 * 1024 }, (req: any, body: string, done) => {
req.federationBody = body
try { done(null, JSON.parse(body)) } catch { done(Object.assign(new Error('Ungültiges JSON'), { statusCode: 400 })) }
})
server.get('/.well-known/fedeo-federation', async () => federation.discovery())
server.post('/federation/v1/rpc', { bodyLimit: 1024 * 1024 }, async (req: any, reply) => {
server.post('/federation/v1/rpc', { bodyLimit: 128 * 1024 }, async (req: any, reply) => {
reply.header('Cache-Control','no-store')
// Duplicate signature-bearing fields are rejected rather than silently combined.
const names = req.raw.rawHeaders.filter((_: string, index: number) => index % 2 === 0).map((name: string) => name.toLowerCase())

View File

@@ -1,139 +0,0 @@
import type { FastifyInstance } from "fastify"
import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm"
import { authUsers, notificationsItems } from "../../../db/schema"
import { NotificationService } from "../notification.service"
import { CollaborationService, rows } from "./service"
import type { FederationService } from "./federation"
import type { Principal } from "./types"
const MESSAGE = "collaboration.message.new"
const INVITATION = "collaboration.room.invited"
/** Queue inside the source transaction; external delivery never holds up a chat command. */
export class CollaborationNotifications {
private working = false
private deferredUntil = new Map<string, number>()
constructor(readonly server: FastifyInstance, readonly service: CollaborationService, readonly federation: FederationService) {}
delivery(db: any) {
return new NotificationService({ db, log: this.server.log } as FastifyInstance, async (server, userId) => {
const [user] = await server.db.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).limit(1)
return user || null
})
}
async enqueue(db: any, principal: Principal, name: string, payload: Record<string, unknown>, message?: { body: string; senderName: string }) {
if (principal.kind !== "user" || principal.disabled) return
try { await this.service.assertPrincipal(principal, db) }
catch (error: any) { if ([403, 404].includes(error.statusCode)) return; throw error }
await this.delivery(db).enqueue({
tenantId: Number(principal.tenant_id), userId: principal.user_id!,
eventType: message ? MESSAGE : INVITATION,
title: message ? `Neue Nachricht in ${name}` : "Einladung zur Zusammenarbeit",
message: message ? `${message.senderName}: ${message.body || "Bild gesendet"}` : `Du wurdest in den Chat „${name}“ eingeladen.`,
channels: ["inapp", "email", "push"],
payload: { ...payload, principalId: principal.id,
link: message ? `/communication/chat?room=${encodeURIComponent(`collaboration:${payload.bindingId || payload.roomId}`)}` : "/communication/chat" },
})
}
async localMessage(db: any, room: any, sender: Principal, message: any) {
const recipients = await rows(db, sql`SELECT p.* FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id
WHERE m.room_id=${room.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())
AND m.history_from_seq<=${message.seq} AND m.last_read_seq<${message.seq}
AND p.kind='user' AND NOT p.disabled AND p.id<>${sender.id}`)
for (const principal of recipients) await this.enqueue(db, principal, room.name,
{ roomId: room.id, messageId: message.id, seq: message.seq }, { body: message.body, senderName: sender.display_name })
}
async localInvitation(db: any, room: any, target: Principal, invitationId: string) {
await this.enqueue(db, target, room.name, { roomId: room.id, invitationId })
}
async remoteInvitation(db: any, principal: Principal, bindingId: string, invitationId: string, name: string) {
await this.enqueue(db, principal, name, { bindingId, invitationId })
}
async remoteMessages(db: any, binding: any, name: string, messages: any[]) {
if (!messages.length) return
const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${binding.principal_id}`)
if (!principal) return
for (const message of new Map(messages.map(message => [message.id, message])).values()) await this.enqueue(db, principal, name,
{ bindingId: binding.id, invitationId: binding.invitation_id, messageId: message.id, seq: message.seq }, message)
}
async eligible(db: any, item: typeof notificationsItems.$inferSelect): Promise<boolean | "defer"> {
const payload = item.payload as any
const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${payload.principalId}`)
if (!principal || principal.kind !== "user" || principal.user_id !== item.userId || Number(principal.tenant_id) !== item.tenantId) return false
try {
await this.service.assertPrincipal(principal, db)
if (payload.bindingId) {
if (!this.federation.options.enabled) return "defer"
const ctx = { principal, userId: item.userId, tenantId: item.tenantId, manage: false }
const binding = await this.federation.binding(ctx, payload.bindingId)
if (!binding || binding.invitation_id !== payload.invitationId) return false
await this.federation.allowed(item.tenantId, binding.origin, db)
if (item.eventType === INVITATION) return binding.status === "pending" && new Date(binding.invitation.expiresAt).getTime() > Date.now()
if (binding.status !== "active") return false
// Do not send cached content while current access cannot be confirmed by its owner.
const detail = await this.federation.sync(ctx, binding)
if (detail.offline) return "defer"
const current = await this.federation.binding(ctx, binding.id)
return current?.status === "active" && current.invitation_id === payload.invitationId
&& payload.seq >= detail.historyFromSeq && (item.channel === "inapp" || payload.seq > Number(current.last_read_seq))
}
const room = await this.service.roomRow(db, payload.roomId)
if (item.eventType === INVITATION) {
this.service.assertOpen(room, true)
const [invite] = await rows(db, sql`SELECT id FROM collaboration_invitations
WHERE id=${payload.invitationId} AND room_id=${room.id} AND target_principal_id=${principal.id}
AND status='pending' AND expires_at>now() AND (access_expires_at IS NULL OR access_expires_at>now())`)
return Boolean(invite)
}
const member = await this.service.membership(db, room, principal)
return payload.seq >= Number(member.history_from_seq) && (item.channel === "inapp" || payload.seq > Number(member.last_read_seq))
} catch (error: any) {
if ([401, 403, 404, 409, 410].includes(error.statusCode)) return false
throw error
}
}
async tick() {
if (this.working) return
this.working = true
try {
for (const [id, until] of this.deferredUntil) if (until <= Date.now()) this.deferredUntil.delete(id)
const pending = await this.server.db.select({ id: notificationsItems.id }).from(notificationsItems)
.where(and(eq(notificationsItems.status, "queued"), inArray(notificationsItems.eventType, [MESSAGE, INVITATION]),
this.deferredUntil.size ? notInArray(notificationsItems.id, [...this.deferredUntil.keys()]) : undefined))
.orderBy(asc(notificationsItems.createdAt)).limit(20)
for (const { id } of pending) {
try {
await this.server.db.transaction(async db => {
const [item] = await db.select().from(notificationsItems)
.where(and(eq(notificationsItems.id, id), eq(notificationsItems.status, "queued")))
.for("update", { skipLocked: true })
if (!item) return
const eligible = await this.eligible(db, item)
if (eligible === "defer") {
this.deferredUntil.set(id, Date.now() + 30000)
return
}
const delivery = this.delivery(db)
const enabled = item.channel === "inapp" || (await delivery.preferences(item.tenantId, item.userId)).events
.some(event => event.eventKey === item.eventType && event.channels[item.channel] === true)
if (!eligible || !enabled) {
await db.delete(notificationsItems).where(eq(notificationsItems.id, item.id))
return
}
await delivery.deliver(item)
})
} catch (error) {
this.deferredUntil.set(id, Date.now() + 30000)
this.server.log.error({ err: error, notificationId: id }, "Collaboration notification failed")
}
}
} finally { this.working = false }
}
}

View File

@@ -4,30 +4,22 @@ import { sendMail } from "../../utils/mailer"
import { CollaborationService } from "./service"
import { FederationService } from "./federation"
import { encrypt, decrypt } from "../../utils/crypt"
import { collaborationConfig } from "./config"
import { CollaborationNotifications } from "./notifications"
export function collaborationRuntime(server: FastifyInstance) {
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
...process.env, API_BASE_URL: secrets.API_BASE_URL, ENCRYPTION_KEY: secrets.ENCRYPTION_KEY,
})
const apiUrl = String(process.env.FEDEO_FEDERATION_URL || secrets.API_BASE_URL || "").replace(/\/$/, "")
const publicUrl = String(process.env.FEDEO_PUBLIC_URL || (apiUrl ? new URL(apiUrl).origin : "")).replace(/\/$/, "")
const service = new CollaborationService(server.db, {
publicUrl,
guestPepper: () => secrets.ENCRYPTION_KEY || "",
sendMail,
})
const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt })
const notifications = new CollaborationNotifications(server, service, federation)
service.options.notifications = notifications
const federation = new FederationService(service, { apiUrl, enabled: process.env.FEDEO_FEDERATION_ENABLED === "true", seal: encrypt, unseal: decrypt })
service.options.changed = (roomId, db) => federation.changed(roomId, db)
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
let timer: ReturnType<typeof setInterval>
server.addHook("onReady", async () => {
if (federation.options.enabled) await federation.keys()
timer = setInterval(() => {
void federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed"))
void notifications.tick().catch(error => server.log.error({ err: error }, "Collaboration notification worker failed"))
}, 5000)
timer = setInterval(() => federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed")), 5000)
timer.unref()
})
server.addHook("onClose", async () => { clearInterval(timer) })

View File

@@ -3,9 +3,6 @@ import { sql } from "drizzle-orm"
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content"
import type { CollaborationNotifications } from "./notifications"
type Db = Pick<NodePgDatabase<any>, "execute" | "transaction">
export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows
export type CollaborationOptions = {
@@ -14,7 +11,6 @@ export type CollaborationOptions = {
sendMail: (to: string, subject: string, html: string) => Promise<{ success: boolean }>;
changed?: (roomId: string, db: any) => Promise<void>;
invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise<void>;
notifications?: CollaborationNotifications;
}
/** Owner-side authority. All membership changes and sends serialize on the room row. */
@@ -144,8 +140,6 @@ export class CollaborationService {
t.name AS "ownerName", m.overview_allowed AS "overviewAllowed",
(SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id
AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread
,coalesce((SELECT max(cm.created_at) FROM collaboration_messages cm WHERE cm.room_id=r.id
AND cm.seq>=m.history_from_seq), r.created_at) AS "lastActivityAt"
FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id
LEFT JOIN projects p ON p.id=r.project_id
WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())
@@ -178,7 +172,7 @@ export class CollaborationService {
const room = await this.roomRow(this.db, roomId)
const member = await this.membership(this.db, room, principal)
const after = sequence(query.after), before = sequence(query.before)
const result = await rows(this.db, sql`SELECT m.id,m.client_id AS "clientId",m.seq::float8 AS seq,m.body,m.image - 'dataUrl' AS image,m.created_at AS "createdAt",
const result = await rows(this.db, sql`SELECT m.id,m.client_id AS "clientId",m.seq::float8 AS seq,m.body,m.created_at AS "createdAt",
p.display_name AS "senderName",p.kind AS "senderKind", m.principal_id=${principal.id} AS own
FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id
WHERE m.room_id=${room.id} AND m.seq>=${member.history_from_seq}
@@ -188,46 +182,22 @@ export class CollaborationService {
}
async send(principal: Principal, roomId: string, input: any) {
const clientId = uuid(input.clientId)
await this.membership(this.db, await this.roomRow(this.db, roomId), principal, true)
const { body, image } = await chatContent(input)
const body = text(input.text, 10000), clientId = uuid(input.clientId)
return this.db.transaction(async (db) => {
const room = await this.roomRow(db, roomId, true)
const member = await this.membership(db, room, principal, true)
if (principal.kind === "remote" && sequence(input.revision) !== Number(member.revision)) fail("Teilnahmerechte haben sich geändert", 409)
const [existing] = await rows(db, sql`SELECT id,client_id AS "clientId",seq::float8 AS seq,body,image,created_at AS "createdAt" FROM collaboration_messages
const [existing] = await rows(db, sql`SELECT id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt" FROM collaboration_messages
WHERE room_id=${room.id} AND principal_id=${principal.id} AND client_id=${clientId}`)
if (existing) {
if (existing.body !== body || (existing.image?.dataUrl || null) !== (image?.dataUrl || null) || (existing.image?.name || null) !== (image?.name || null)) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409)
return { ...existing, image: imageMetadata(existing.image), own: true, senderName: principal.display_name, senderKind: principal.kind }
if (existing.body !== body) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409)
return { ...existing, own: true, senderName: principal.display_name, senderKind: principal.kind }
}
const [next] = await rows(db, sql`UPDATE collaboration_rooms SET sequence=sequence+1,updated_at=now() WHERE id=${room.id} RETURNING sequence`)
const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image)
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body)
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body}) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
await this.options.changed?.(room.id, db)
await this.options.notifications?.localMessage(db, room, principal, message)
return { ...message, image: imageMetadata(image), own: true, senderName: principal.display_name, senderKind: principal.kind }
})
}
async image(principal: Principal, roomId: string, input: any) {
const room = await this.roomRow(this.db, roomId)
const member = await this.membership(this.db, room, principal)
return chatImage(this.db, "collaboration", room.id, input?.messageId, Number(member.history_from_seq))
}
async reactions(principal: Principal, roomId: string, input: any) {
const room = await this.roomRow(this.db, roomId)
const member = await this.membership(this.db, room, principal)
return chatReactions(this.db, "collaboration", room.id, principal.id, input?.ids, Number(member.history_from_seq))
}
async react(principal: Principal, roomId: string, input: any) {
return this.db.transaction(async db => {
const room = await this.roomRow(db, roomId, true)
const member = await this.membership(db, room, principal, true)
if (principal.kind === "remote" && sequence(input?.revision) !== Number(member.revision)) fail("Teilnahmerechte haben sich geändert", 409)
return setChatReaction(db, "collaboration", room.id, principal.id, input, Number(member.history_from_seq))
return { ...message, own: true, senderName: principal.display_name, senderKind: principal.kind }
})
}
@@ -307,7 +277,6 @@ export class CollaborationService {
if (!this.options.invited) fail("Föderation ist nicht konfiguriert", 503)
await this.options.invited(ctx, room.id, target, invite.id, db)
}
await this.options.notifications?.localInvitation(db, room, target, invite.id)
return invite
})
return { ...result, token }

View File

@@ -15,18 +15,9 @@ export function managesProjects(req: any): boolean {
return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write"))
}
export function projectChatSettingError(req: any, body: Record<string, unknown>, previous = true) {
if (!Object.prototype.hasOwnProperty.call(body, "chatEnabled")) return null
if (typeof body.chatEnabled !== "boolean") return { statusCode: 400, error: "Projektchat aktiviert muss ein Wahrheitswert sein" }
if (body.chatEnabled !== previous && !managesProjects(req)) {
return { statusCode: 403, error: "Keine Berechtigung zum Ändern des Projektchats" }
}
return null
}
export async function projectChatAccess(db: any, req: any, projectId?: number) {
const tenantId = Number(req.user?.tenant_id)
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false), eq(projects.chatEnabled, true)]
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false)]
if (projectId !== undefined) conditions.push(eq(projects.id, projectId))
const rows = await db.select().from(projects).where(and(...conditions))
if (managesProjects(req)) return rows

View File

@@ -17,7 +17,7 @@ import {
incominginvoices,
} from "../../../db/schema"
import { eq, and, inArray, isNull, not } from "drizzle-orm"
import { eq, and, isNull, not } from "drizzle-orm"
const formatInvoiceItemDescription = (item: any) => {
const parts = [
@@ -86,25 +86,8 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
return itemInfo
}
export type IncomingInvoicePreparationResult = {
filesFound: number
created: Array<{ fileId: string, invoiceId: number, source: string | null }>
skipped: Array<{ fileId: string, reason: string }>
error?: string
}
export type IncomingInvoicePreparationStatus = {
running: boolean
startedAt: string | null
finishedAt: string | null
result: IncomingInvoicePreparationResult | null
}
class FileAlreadyPreparedError extends Error {}
export function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number, fileIds?: string[]) => {
const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] }
const processInvoices = async (tenantId:number) => {
console.log("▶ Starting Incoming Invoice Preparation")
const tenantsRes = await server.db
@@ -115,7 +98,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
if (!tenantsRes.length) {
console.log("No tenants with autoPrepareIncomingInvoices = true")
return { ...result, error: "Mandant nicht gefunden" }
return
}
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
@@ -141,7 +124,6 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
const invoiceFileTag = tagRes?.[0]?.id
if (!invoiceFileTag) {
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
result.error = "Dateityp für Eingangsrechnungen fehlt"
continue
}
@@ -155,13 +137,10 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
eq(files.type, invoiceFileTag),
isNull(files.incominginvoice),
eq(files.archived, false),
not(isNull(files.path)),
...(fileIds ? [inArray(files.id, fileIds)] : [])
not(isNull(files.path))
)
)
result.filesFound += filesRes.length
if (!filesRes.length) {
console.log(`No invoice files for tenant ${tenantId}`)
continue
@@ -171,152 +150,95 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
// -------------------------------------------------------------
for (const file of filesRes) {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
let fileData: Buffer
try {
await processFile(tenantId, file, result)
fileData = await loadFileBuffer(file.path!)
} catch (error) {
if (error instanceof FileAlreadyPreparedError) {
result.skipped.push({ fileId: file.id, reason: "Datei wurde bereits vorbereitet" })
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
continue
}
const electronicInvoice = await detectElectronicInvoice(fileData, file)
let itemInfo: any = null
if (electronicInvoice) {
server.log.info({
fileId: file.id,
container: electronicInvoice.container,
syntax: electronicInvoice.syntax,
attachmentName: electronicInvoice.attachmentName,
}, "Strukturierte E-Rechnung erkannt.")
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.`)
if (electronicInvoice.container === "xml") continue
}
}
if (!itemInfo) {
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)
continue
}
server.log.error(error, `Datei ${file.id} konnte nicht vorbereitet werden.`)
result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" })
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
}
// ---------------------------------------------------------
// 4️⃣ IncomingInvoice erstellen
// ---------------------------------------------------------
const inserted = await server.db
.insert(incominginvoices)
.values(itemInfo)
.returning()
const newInvoice = inserted?.[0]
if (!newInvoice) {
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
continue
}
// ---------------------------------------------------------
// 5️⃣ Datei mit incominginvoice-ID verbinden
// ---------------------------------------------------------
await server.db
.update(files)
.set({ incominginvoice: newInvoice.id })
.where(eq(files.id, file.id))
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
}
}
return result
return
}
const processFile = async (tenantId: number, file: typeof files.$inferSelect, result: IncomingInvoicePreparationResult) => {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
let fileData: Buffer
try {
fileData = await loadFileBuffer(file.path!)
} catch (error) {
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" })
return
}
const electronicInvoice = await detectElectronicInvoice(fileData, file)
let itemInfo: any = null
if (electronicInvoice) {
server.log.info({
fileId: file.id,
container: electronicInvoice.container,
syntax: electronicInvoice.syntax,
attachmentName: electronicInvoice.attachmentName,
}, "Strukturierte E-Rechnung erkannt.")
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.`)
if (electronicInvoice.container === "xml") {
result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" })
return
}
}
}
if (!itemInfo) {
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" })
return
}
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
}
// ---------------------------------------------------------
// 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen.
// Die Verknüpfung greift nur, solange die Datei noch frei ist;
// sonst wird der Beleg verworfen, damit keine Dubletten entstehen.
// ---------------------------------------------------------
const newInvoice = await server.db.transaction(async (tx) => {
const [invoice] = await tx
.insert(incominginvoices)
.values(itemInfo)
.returning()
if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden")
const linked = await tx
.update(files)
.set({ incominginvoice: invoice.id })
.where(and(eq(files.id, file.id), isNull(files.incominginvoice)))
.returning({ id: files.id })
if (!linked.length) throw new FileAlreadyPreparedError()
return invoice
})
result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null })
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
}
// Serialize runs per tenant so concurrent triggers cannot prepare the same file twice.
const running = new Map<number, Promise<unknown>>()
const backgroundRuns = new Map<number, IncomingInvoicePreparationStatus>()
const run = async (tenant:number, options: { fileIds?: string[] } = {}) => {
const previous = running.get(tenant) || Promise.resolve()
const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds))
running.set(tenant, current)
try {
const result = await current
return {
run: async (tenant:number) => {
await processInvoices(tenant)
console.log("Incoming Invoice Preparation Completed.")
return result
} finally {
if (running.get(tenant) === current) running.delete(tenant)
}
}
const status = (tenant: number): IncomingInvoicePreparationStatus =>
backgroundRuns.get(tenant) || { running: false, startedAt: null, finishedAt: null, result: null }
// Startet die Vorbereitung losgelöst von der Anfrage; ein laufender Vorgang wird nicht erneut gestartet.
const start = (tenant: number): IncomingInvoicePreparationStatus => {
const current = status(tenant)
if (current.running) return current
const next: IncomingInvoicePreparationStatus = { running: true, startedAt: new Date().toISOString(), finishedAt: null, result: null }
backgroundRuns.set(tenant, next)
run(tenant)
.then((result) => {
backgroundRuns.set(tenant, { ...next, running: false, finishedAt: new Date().toISOString(), result })
})
.catch((error) => {
server.log.error(error, `Belegvorbereitung für Mandant ${tenant} fehlgeschlagen.`)
backgroundRuns.set(tenant, {
...next,
running: false,
finishedAt: new Date().toISOString(),
result: { filesFound: 0, created: [], skipped: [], error: "Belegvorbereitung fehlgeschlagen" },
})
})
return next
}
return { run, start, status }
}

View File

@@ -1,126 +0,0 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { costcentres, incominginvoices } from "../../db/schema"
import { insertHistoryItem } from "../utils/history"
export type AccountCostCentreAssignment = {
index: number
costcentre: string | null
}
export type IncomingInvoiceCostCentreUpdate = {
accountCostCentres?: AccountCostCentreAssignment[]
}
const serviceError = (message: string, statusCode = 400) =>
Object.assign(new Error(message), { statusCode })
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function normalizeAccountCostCentreAssignments(input: unknown): AccountCostCentreAssignment[] {
if (!Array.isArray(input)) throw serviceError("accountCostCentres muss ein Array sein")
if (!input.length) throw serviceError("Mindestens eine Positionskostenstelle ist erforderlich")
const seen = new Set<number>()
return input.map((assignment) => {
const index = assignment?.index
const costcentre = assignment?.costcentre
if (!Number.isInteger(index) || index < 0) throw serviceError("Jede Positionszuordnung benötigt einen gültigen index")
if (seen.has(index)) throw serviceError(`Position ${index + 1} ist mehrfach angegeben`)
seen.add(index)
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
throw serviceError(`Ungültige Kostenstelle für Position ${index + 1}`)
}
return { index, costcentre }
})
}
export function applyAccountCostCentreAssignments(
accounts: unknown,
assignments: AccountCostCentreAssignment[] = [],
) {
if (!Array.isArray(accounts)) throw serviceError("Der Eingangsbeleg enthält keine Positionen")
for (const { index } of assignments) {
const account = accounts[index]
if (!account || typeof account !== "object" || Array.isArray(account)) {
throw serviceError(`Position ${index + 1} wurde im Eingangsbeleg nicht gefunden`)
}
}
const assignmentMap = new Map(assignments.map((assignment) => [assignment.index, assignment.costcentre]))
return accounts.map((account, index) => {
if (!assignmentMap.has(index)) return account
const { costcentre: _legacyCostCentre, ...rest } = account as Record<string, unknown>
return { ...rest, costCentre: assignmentMap.get(index) ?? null }
})
}
export async function updateIncomingInvoiceCostCentres(
server: FastifyInstance,
tenantId: number,
userId: string,
invoiceId: number,
input: IncomingInvoiceCostCentreUpdate,
) {
const assignments = normalizeAccountCostCentreAssignments(input?.accountCostCentres)
const [existing] = await server.db
.select()
.from(incominginvoices)
.where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false),
))
.limit(1)
if (!existing) throw serviceError("Eingangsbeleg nicht gefunden", 404)
const referencedCostCentreIds = [...new Set(
assignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
)]
if (referencedCostCentreIds.length) {
const validCostCentres = await server.db
.select({ id: costcentres.id })
.from(costcentres)
.where(and(
eq(costcentres.tenant, tenantId),
eq(costcentres.archived, false),
inArray(costcentres.id, referencedCostCentreIds),
))
if (validCostCentres.length !== referencedCostCentreIds.length) {
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
}
}
const [updated] = await server.db
.update(incominginvoices)
.set({
accounts: applyAccountCostCentreAssignments(existing.accounts, assignments),
updatedAt: new Date(),
updatedBy: userId,
})
.where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false),
))
.returning()
if (!updated) throw serviceError("Eingangsbeleg nicht gefunden", 404)
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: "incominginvoices",
entityId: invoiceId,
action: "updated",
oldVal: existing,
newVal: updated,
text: "Kostenstellen des Eingangsbelegs geändert",
})
return updated
}

View File

@@ -56,24 +56,6 @@ export class NotificationService {
) {}
async trigger(input: TriggerInput) {
const { items, skipped } = await this.createItems(input)
if (skipped !== undefined) return { success: true, created: 0, delivered: 0, skipped }
const results = []
for (const item of items) results.push(await this.deliver(item))
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
}
/** Persist first; callers using a transaction can deliver after it commits. */
async enqueue(input: TriggerInput) {
return (await this.createItems(input)).items
}
private async createItems(input: TriggerInput): Promise<{ items: (typeof notificationsItems.$inferSelect)[]; skipped?: number }> {
const tenantId = input.tenantId
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
@@ -86,7 +68,7 @@ export class NotificationService {
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
if (!channels.length) {
return { items: [], skipped: userIds.length }
return { success: true, created: 0, delivered: 0, skipped: userIds.length }
}
const results = []
@@ -117,47 +99,16 @@ export class NotificationService {
const item = itemRows[0]
if (!item) continue
results.push(item)
results.push(await this.deliver(item))
}
}
return { items: results }
}
async preferences(tenantId: number, userId: string) {
const events = await this.server.db.select().from(notificationsEventTypes)
.where(eq(notificationsEventTypes.isActive, true))
const rows = []
for (const event of events.filter((event) => event.category !== "system")) {
const channels = this.normalizeChannels(event.allowedChannels).filter((channel) => channel === "email" || channel === "push")
const enabled = await this.resolveEnabledChannels({ tenantId, userId, eventType: event.eventKey, channels })
rows.push({ eventKey: event.eventKey, displayName: event.displayName, description: event.description,
channels: Object.fromEntries(channels.map((channel) => [channel, enabled.includes(channel)])) })
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
return { tenantId, events: rows }
}
async savePreferences(tenantId: number, userId: string, input: unknown) {
const invalid = () => Object.assign(new Error("Ungültige Benachrichtigungseinstellungen"), { statusCode: 400 })
if (!Array.isArray(input) || input.length > 200) throw invalid()
const available = await this.preferences(tenantId, userId)
const seen = new Set<string>()
const values = input.map((value) => {
if (!value || typeof value !== "object") throw invalid()
const { eventType, channel, enabled } = value
if (typeof eventType !== "string" || (channel !== "email" && channel !== "push")) throw invalid()
const event = available.events.find((event) => event.eventKey === eventType)
const key = `${eventType}:${channel}`
if (!event || !Object.prototype.hasOwnProperty.call(event.channels, channel) || typeof enabled !== "boolean" || seen.has(key)) throw invalid()
seen.add(key)
return { tenantId, userId, eventType: String(eventType), channel: channel as NotificationChannel, enabled }
})
await this.server.db.transaction(async (tx) => {
for (const value of values) await tx.insert(notificationsPreferences).values(value)
.onConflictDoUpdate({ target: [notificationsPreferences.tenantId, notificationsPreferences.userId,
notificationsPreferences.eventType, notificationsPreferences.channel], set: { enabled: value.enabled } })
})
return await this.preferences(tenantId, userId)
}
async listForUser(tenantId: number, userId: string, limit = 50) {
@@ -308,12 +259,11 @@ export class NotificationService {
const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled
if (input.eventType === "collaboration.message.new") return channel === "inapp"
return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
return true
})
}
async deliver(item: typeof notificationsItems.$inferSelect) {
private async deliver(item: typeof notificationsItems.$inferSelect) {
if (item.channel === "inapp") {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }

View File

@@ -1,4 +1,3 @@
import { TimeService } from "./time-v2/service";
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
@@ -201,14 +200,6 @@ export const publicLinkService = {
},
async submitFormData(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
return server.db.transaction(async tx => {
const scoped = Object.create(server);
scoped.db = tx;
return publicLinkService.submitFormDataTransaction(scoped, token, payload, providedPin);
});
},
async submitFormDataTransaction(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
// 1. Validierung (Token & PIN)
const linkConfig = await server.db.query.publicLinks.findFirst({
where: eq(schema.publicLinks.token, token)
@@ -232,7 +223,7 @@ export const publicLinkService = {
// Profil laden, um die user_id zu bekommen
const authProfile = await server.db.query.authProfiles.findFirst({
where: and(eq(schema.authProfiles.id, rawProfileId), eq(schema.authProfiles.tenant_id, tenantId))
where: eq(schema.authProfiles.id, rawProfileId)
});
if (!authProfile) throw new Error("Profile_Not_Found");
@@ -254,7 +245,7 @@ export const publicLinkService = {
// SCHRITT A: Stammdaten laden
// =================================================================
const project = await server.db.query.projects.findFirst({
where: and(eq(schema.projects.id, payload.project), eq(schema.projects.tenant, tenantId))
where: eq(schema.projects.id, payload.project)
});
if (!project) throw new Error("Project not found");
@@ -360,42 +351,29 @@ export const publicLinkService = {
generatedDocumentId: createdDoc.id
};
const timeService = new TimeService(server.db);
const actor = { tenant: tenantId, user: userId, trusted: true };
await timeService.lock(server.db, actor, userId);
if (await timeService.active(actor, userId)) {
await timeService.mutate(actor, userId, `public-link:${createdDoc.id}`, {
action: 'create', data: {
type: 'work', started_at: startDate.toISOString(), ended_at: endDate.toISOString(),
description: eventMetadata.description, breaks: [],
projects: [{ project_id: project.id, started_at: startDate.toISOString(), ended_at: endDate.toISOString(), notes: eventMetadata.description }],
},
}, 'public_link');
} else {
// 1. START EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId, // WICHTIG: User ID
actortype: "user",
actoruser_id: userId, // WICHTIG: User ID
eventtime: startDate,
eventtype: "START",
source: "PUBLIC_LINK",
metadata: eventMetadata // WICHTIG: Schema heißt 'metadata', nicht 'payload'
});
// 1. START EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId, // WICHTIG: User ID
actortype: "user",
actoruser_id: userId, // WICHTIG: User ID
eventtime: startDate,
eventtype: "START",
source: "PUBLIC_LINK",
metadata: eventMetadata // WICHTIG: Schema heißt 'metadata', nicht 'payload'
});
// 2. STOP EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: endDate,
eventtype: "STOP",
source: "PUBLIC_LINK",
metadata: eventMetadata
});
}
// 2. STOP EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: endDate,
eventtype: "STOP",
source: "PUBLIC_LINK",
metadata: eventMetadata
});
}
// =================================================================

View File

@@ -36,13 +36,13 @@ export function assignmentId(kind: string, id: unknown) {
return Number(id)
}
type Reader = Pick<FastifyInstance["db"], "select">
export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "", limit = 100, offset = 0) {
export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "") {
const target = targetFor(kind)
const name = sql<string>`coalesce(nullif(${target.name}, ''), '#' || ${target.id}::text)`
const rows = await db.select({ id: target.id, name }).from(target.table)
.where(and(eq(target.tenant, tenantId), target.available,
search ? ilike(name, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined))
.orderBy(asc(target.name), asc(target.id)).limit(limit).offset(offset)
.orderBy(asc(target.name), asc(target.id)).limit(100)
return rows.map(row => ({ id: String(row.id), name: row.name }))
}
export async function currentAssignments(db: Reader, tenantId: number, file: Record<string, unknown>) {

View File

@@ -184,29 +184,6 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
if (!file) throw scannerError(404, "Dokument nicht gefunden.")
return currentAssignments(db, tenantId, file)
}
const archiveDocument = async (tenantId: number, userId: string, fileId: string) => db.transaction(async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, tenantId)).for("share")
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
// Use the same lock order as filing so both actions cannot succeed concurrently.
const [entry] = await tx.select({ filedAt: scannerInboxDocuments.filedAt }).from(scannerInboxDocuments)
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId))).for("update")
if (!entry) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
if (entry.filedAt) throw scannerError(409, "Dieses Dokument wurde bereits abgelegt. Bitte den Eingang aktualisieren.")
const [file] = await tx.select({ archived: files.archived }).from(files)
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId))).for("update")
if (!file) throw scannerError(404, "Dokument nicht gefunden.")
if (!file.archived) {
await tx.update(files).set({ archived: true, updatedAt: new Date(), updatedBy: userId })
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
}
// Keep the receipt for scanner retries; invalidate any analysis already in flight.
await tx.update(scannerInboxDocuments).set({ suggestionClaim: null,
suggestionStatus: sql`CASE WHEN ${scannerInboxDocuments.suggestionStatus} = 'processing' THEN 'pending' ELSE ${scannerInboxDocuments.suggestionStatus} END` })
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
return { id: fileId, archived: true }
})
const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string, assignments: FileAssignments = {}) => db.transaction(async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, tenantId)).for("share")
@@ -264,34 +241,32 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
// Epson probes writability with an empty PUT. No file, receipt or reservation is stored.
})
}
const storeDocument = async (tenantId: number, filename: string, body: Buffer,
authorize: (tx: Pick<FastifyInstance["db"], "select">) => Promise<void>, onlyIfNew = false, userId?: string) => {
const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
const contentType = validateScannerFile(filename, body)
const checksum = digest(body)
let uploadedKey: string | undefined
try {
return await db.transaction(async tx => {
await authorize(tx)
await assertWritable(tx, identity)
// Serialise retries across processes; a lost HTTP response must not duplicate a document.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${tenantId}:${filename}`}, 0))`)
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`)
const [existing] = await tx.select().from(scannerInboxDocuments).where(and(
eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
eq(scannerInboxDocuments.tenantId, identity.tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
)).limit(1)
if (existing) {
if (onlyIfNew) throw scannerError(412, "Der Dateiname ist bereits vorhanden.")
if (existing.checksum !== checksum) throw scannerError(409, "Der Dateiname ist bereits vergeben. Bitte einen eindeutigen Dateinamen verwenden.")
return { id: existing.fileId, created: false, checksum }
return { created: false, checksum }
}
const id = randomUUID()
const key = `${tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
const key = `${identity.tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
uploadedKey = key
await storage.put(key, body, contentType)
// Bypass saveFile's text extraction: the inbox only stores the original document.
await tx.insert(files).values({ id, tenant: tenantId, name: filename, path: key, mimeType: contentType, size: body.length,
...(userId ? { createdBy: userId } : {}) })
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId, remoteFilename: filename, checksum })
return { id, created: true, checksum }
await tx.insert(files).values({ id, tenant: identity.tenantId, name: filename, path: key, mimeType: contentType, size: body.length })
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId: identity.tenantId, remoteFilename: filename, checksum })
return { created: true, checksum }
})
} catch (error) {
if (uploadedKey) {
@@ -301,17 +276,8 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
throw error
}
}
const accept = (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) =>
storeDocument(identity.tenantId, filename, body, tx => assertWritable(tx, identity), onlyIfNew)
// Internal callers authorize the user before invoking this method; scanner credentials remain separate.
const upload = (tenantId: number, userId: string, filename: string, body: Buffer) =>
storeDocument(tenantId, filename, body, async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants).where(eq(tenants.id, tenantId)).for("share")
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
}, false, userId)
return { connection, credentials, disable, authenticate, find, list, openCount, filingOptions, getSuggestion, queueSuggestion, processNextSuggestion,
getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept, upload }
getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), fileDocument, probe, accept }
}
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>

View File

@@ -14,60 +14,6 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
dayjs.extend(quarterOfYear);
// Cancellation and finalization compete through conditional status updates.
export const cancelManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
return server.db.transaction(async (tx) => {
const [execution] = await tx.select().from(schema.serialExecutions).where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
)).for("update");
if (!execution) throw Object.assign(new Error("Durchlauf nicht gefunden."), { statusCode: 404 });
if (execution.status === "completed") {
throw Object.assign(new Error("Abgeschlossene Durchläufe können nicht abgebrochen werden."), { statusCode: 409 });
}
if (["cancelled", "cancelling"].includes(execution.status)) return { execution };
const status = ["generating", "processing"].includes(execution.status) ? "cancelling" : "cancelled";
if (status === "cancelled") await archiveExecutionDrafts(tx, executionId, tenantId);
const [updated] = await tx.update(schema.serialExecutions).set({
status,
summary: status === "cancelling" ? "Abbruch angefordert." : "Abgebrochen. Ungebuchte Entwürfe wurden archiviert.",
}).where(and(eq(schema.serialExecutions.id, executionId), eq(schema.serialExecutions.tenant, tenantId))).returning();
return { execution: updated };
});
};
async function archiveExecutionDrafts(db: any, executionId: string, tenantId: number) {
await db.update(schema.createddocuments).set({ archived: true }).where(and(
eq(schema.createddocuments.serialexecution, executionId),
eq(schema.createddocuments.tenant, tenantId),
eq(schema.createddocuments.state, "Entwurf"),
eq(schema.createddocuments.archived, false),
));
}
async function isCancellationRequested(server: FastifyInstance, executionId: string, tenantId: number) {
const [record] = await server.db.select().from(schema.serialExecutions).where(and(
eq(schema.serialExecutions.id, executionId), eq(schema.serialExecutions.tenant, tenantId),
));
return record?.status === "cancelling";
}
async function settleExecution(server: FastifyInstance, executionId: string, tenantId: number, status: string, summary?: string) {
return server.db.transaction(async (tx) => {
const [record] = await tx.select().from(schema.serialExecutions).where(and(
eq(schema.serialExecutions.id, executionId), eq(schema.serialExecutions.tenant, tenantId),
)).for("update");
if (record.status === "cancelling") {
await archiveExecutionDrafts(tx, executionId, tenantId);
status = "cancelled";
summary = "Abgebrochen. Ungebuchte Entwürfe wurden archiviert; gebuchte Rechnungen bleiben erhalten.";
}
const [updated] = await tx.update(schema.serialExecutions).set({ status, ...(summary ? { summary } : {}) })
.where(and(eq(schema.serialExecutions.id, executionId), eq(schema.serialExecutions.tenant, tenantId))).returning();
return updated;
});
}
export const executeManualGeneration = async (
server: FastifyInstance,
executionDate: string | Date,
@@ -120,7 +66,7 @@ export const executeManualGeneration = async (
.values({
tenant: tenantId,
executionDate: executionDayjs.toDate(),
status: "generating",
status: "draft",
createdBy: executedBy,
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
})
@@ -128,45 +74,50 @@ export const executeManualGeneration = async (
console.log(executionRecord);
try {
// 4. Loop durch die Templates
for (const template of templates) {
if (await isCancellationRequested(server, executionRecord.id, tenantId)) break;
try {
const resultId = await processSingleTemplate(
server,
template,
tenant,
executionDayjs,
folderId,
fileTypeId,
executedBy,
executionRecord.id
);
results.push({ id: template.id, status: "success", newDocumentId: resultId });
} catch (e: any) {
console.error(`Fehler bei Template ${template.id}:`, e);
results.push({ id: template.id, status: "error", error: e.message });
}
// 4. Loop durch die Templates
for (const template of templates) {
try {
const resultId = await processSingleTemplate(
server,
template,
tenant,
executionDayjs,
folderId,
fileTypeId,
executedBy,
executionRecord.id
);
results.push({ id: template.id, status: "success", newDocumentId: resultId });
} catch (e: any) {
console.error(`Fehler bei Template ${template.id}:`, e);
results.push({ id: template.id, status: "error", error: e.message });
}
const execution = await settleExecution(server, executionRecord.id, tenantId, "draft");
return { execution, results };
} catch (error) {
await settleExecution(server, executionRecord.id, tenantId, "error", "Fehler beim Vorbereiten der Rechnungen.");
throw error;
}
return { execution: executionRecord, results };
}
export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
const [executionRecord] = await server.db.update(schema.serialExecutions)
.set({ status: "processing" }).where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
inArray(schema.serialExecutions.status, ["draft", "error"]),
)).returning();
if (!executionRecord) throw Object.assign(new Error("Durchlauf kann nicht fertiggestellt werden."), { statusCode: 409 });
try {
console.log(`Beende Ausführung ${executionId}...`);
// 1. Execution und Tenant laden
const [executionRecord] = await server.db
.select()
.from(schema.serialExecutions)// @ts-ignore
.where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
))
.limit(1);
if (!executionRecord) throw new Error("Execution nicht gefunden");
console.log(executionRecord);
console.log(tenantId)
// Tenant laden (für Settings etc.)
const [tenant] = await server.db
.select()
@@ -174,13 +125,19 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
.where(eq(schema.tenants.id, tenantId))
.limit(1);
// 2. Status auf "processing" setzen (optional, damit UI feedback hat)
/*await server.db
.update(schema.serialExecutions)
.set({ status: "processing" })// @ts-ignore
.where(eq(schema.serialExecutions.id, executionId));*/
// 3. Alle erstellten Dokumente dieser Execution laden
const documents = await server.db
.select()
.from(schema.createddocuments)
.where(and(
eq(schema.createddocuments.serialexecution, executionId),
eq(schema.createddocuments.state, "Entwurf"),
eq(schema.createddocuments.tenant, tenantId),
eq(schema.createddocuments.archived, false),
));
@@ -203,7 +160,6 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
// 5. Loop durch Dokumente
for (const doc of documents) {
if (await isCancellationRequested(server, executionId, tenantId)) break;
try {
const [letterhead] = await Promise.all([
@@ -274,11 +230,31 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
const finalStatus = errorCount > 0 ? "error" : "completed"; // Oder 'completed' auch wenn Teilerfolge
const execution = await settleExecution(server, executionId, tenantId, finalStatus,
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`);
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
await server.db
.update(schema.serialExecutions)
.set({
status: finalStatus,
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
})// @ts-ignore
.where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
));
return { success: true, processed: successCount, errors: errorCount };
} catch (error) {
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
console.error("Critical Error in finishManualGeneration:", error);
// Execution auf Error setzen
// @ts-ignore
await server.db
.update(schema.serialExecutions)
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
//@ts-ignore
.where(and(
eq(schema.serialExecutions.id, executionId),
eq(schema.serialExecutions.tenant, tenantId),
));
throw error;
}
}
@@ -675,7 +651,6 @@ export function getDocumentDataBackend(
// --- 7. Construct Final Object ---
// Adresse aufbereiten
const recipientCountry = String(itemInfo.address?.country ?? customerData.infoData?.country ?? customerData.country ?? "").trim();
const recipientArray = [
customerData.name,
...(customerData.nameAddition ? [customerData.nameAddition] : []),
@@ -683,7 +658,6 @@ export function getDocumentDataBackend(
itemInfo.address?.street || customerData.street || "",
...(itemInfo.address?.special ? [itemInfo.address.special] : []),
`${itemInfo.address?.zip || customerData.zip} ${itemInfo.address?.city || customerData.city}`,
...(recipientCountry && recipientCountry.toLocaleLowerCase("de") !== "deutschland" ? [recipientCountry] : []),
].filter(Boolean); // Leere Einträge entfernen
console.log(contactPerson)

View File

@@ -1,108 +0,0 @@
import { randomUUID } from 'node:crypto';
import { sql } from 'drizzle-orm';
import { FastifyInstance } from 'fastify';
import { Actor, authorize, digest, rows, TimeService } from './service';
import { fail, Span } from './domain';
import { timeActor, uuid } from '../../routes/staff/time-v2';
/** Existing consumers still receive work segments with pauses excluded, backed by a stable span ID. */
export function legacySpans(spans: Span[]) {
return spans.flatMap(s => {
const base = { id: s.id, version: s.version, sourceEventIds: [s.id], status: s.status, user_id: s.user_id, description: s.description, payload: { description: s.description }, model: 'spans-v2', attendance: s };
if (s.type !== 'work')
return [{ ...base, type: s.type, startedAt: s.started_at, endedAt: s.ended_at }];
const result: any[] = [];
let start = s.started_at;
for (const p of s.breaks) {
if (Date.parse(start) < Date.parse(p.started_at))
result.push({ ...base, type: 'work', startedAt: start, endedAt: p.started_at });
result.push({ ...base, type: 'pause', startedAt: p.started_at, endedAt: p.ended_at });
if (!p.ended_at)
return result;
start = p.ended_at;
}
if (!s.ended_at || Date.parse(start) < Date.parse(s.ended_at))
result.push({ ...base, type: 'work', startedAt: start, endedAt: s.ended_at });
return result;
});
}
export async function legacyEvent(service: TimeService, a: Actor, user: string, body: any, requestId?: string, source = 'legacy') {
authorize(a, user, 'write');
const key = `legacy:${digest({ body, requestId, actor: a.user, source })}`;
const [cached] = await rows(service.db, sql `SELECT response FROM staff_time_v2_requests WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND request_id=${key}`);
if (cached)
return cached.response;
const at = body.eventtime, type = body.eventtype;
const command: any = { at };
const active = await service.current({ ...a, trusted: true }, user);
if (type === 'work_start')
Object.assign(command, { action: 'start', description: body.payload?.description });
else if (['work_end', 'auto_stop', 'pause_start', 'pause_end'].includes(type)) {
if (!active)
fail('Keine laufende Anwesenheit.', 409);
Object.assign(command, { action: type === 'pause_start' ? 'pause' : type === 'pause_end' ? 'resume' : 'stop', id: active.id, version: active.version });
}
else if (/^(vacation|sick|overtime_compensation|holiday|other)_start$/.test(type)) {
Object.assign(command, { action: 'create', data: { type: type.slice(0, -6), started_at: at, ended_at: null, description: body.payload?.description || '' } });
}
else if (/^(vacation|sick|overtime_compensation|holiday|other)_end$/.test(type)) {
if (!active || active.type !== type.slice(0, -4))
fail('Kein passender laufender Eintrag.', 409);
Object.assign(command, { action: 'update', id: active.id, version: active.version, reason: 'Abschluss über bestehende Erfassung', data: { ...active, ended_at: at } });
}
else
fail('Unbekannter Ereignistyp.');
// For clients without IDs, the exact event payload is the stable key. Resolve retries before constructing a changed version.
return service.mutate(a, user, key, command, source);
}
export function installTimeCompatibility(server: FastifyInstance, internal = false) {
const service = new TimeService(server.db);
server.addHook('preHandler', async (req, reply) => {
try {
const path = req.url.split('?')[0], b = (req.body || {}) as any, q = req.query as any;
const operation = path.split('/').pop()!;
const a: Actor = internal ? { ...timeActor(req), trusted: true } : timeActor(req);
// Internal keys remain bound to their authenticated tenant and user context.
if (internal && ((b.tenant_id && Number(b.tenant_id) !== a.tenant) || (q.tenantId && Number(q.tenantId) !== a.tenant)))
fail('Falscher Mandant.', 403);
let user = uuid(b.user_id || b.employeeUserId || q.targetUserId || a.user);
const ids = b.eventIds || b.originalEventIds;
if (Array.isArray(ids) && ids.length) {
const [owner] = await rows(server.db, sql `SELECT user_id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND id=${uuid(ids[0])}::uuid`);
if (owner)
user = owner.user_id;
}
authorize(a, user, ['approve', 'reject'].includes(operation) ? 'approve' : req.method === 'GET' ? 'read' : 'write');
if (!await service.active(a, user))
return;
if (operation === 'spans') {
const spans = await service.list(a, user, q.from, q.to);
const current = await service.current(a, user);
if (current && !spans.some(s => s.id === current.id)) spans.push(current);
return reply.send(legacySpans(spans));
}
if (operation === 'evaluation') {
const result = await service.evaluation(a, user, q.from, q.to);
return reply.send({ ...result, spans: legacySpans(result.spans) });
}
if (operation === 'event')
return reply.send(await legacyEvent(service, a, user, b, req.headers['idempotency-key'] as string, internal ? 'terminal' : 'legacy'));
if (operation === 'edit')
fail('Dieser Eintrag wird unter 2.0 → Zeiterfassung einschließlich seiner Pausen bearbeitet.', 409);
if (['submit', 'approve', 'reject'].includes(operation)) {
if (!Array.isArray(ids) || ids.length !== 1)
fail('Bitte vollständige Anwesenheiten einzeln unter 2.0 bearbeiten.', 409);
const before = await service.get(a, uuid(ids[0]));
if (before.status === ({ submit: 'submitted', approve: 'approved', reject: 'rejected' } as any)[operation])
return reply.send(before);
const result = await service.mutate(a, user, req.headers['idempotency-key'] as string || randomUUID(), {
action: operation, id: before.id, version: before.version, reason: b.reason,
}, 'legacy');
return reply.send(result);
}
}
catch (error: any) {
req.log.error(error);
return reply.code(error.statusCode || 400).send({ error: error.statusCode ? error.message : 'Zeitaktion konnte nicht verarbeitet werden.' });
}
});
}

View File

@@ -1,167 +0,0 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
export const TYPES = ['work', 'vacation', 'sick', 'overtime_compensation', 'holiday', 'other'] as const;
export type TimeType = typeof TYPES[number];
export type Period = {
started_at: string;
ended_at: string | null;
};
export type Span = Period & {
id?: string;
user_id?: string;
type: TimeType;
description: string;
status: string;
version?: number;
date_from?: string | null;
date_to?: string | null;
timezone: string;
breaks: Period[];
projects: (Period & {
project_id: number;
notes: string;
})[];
};
export class TimeError extends Error {
constructor(public statusCode: number, message: string) { super(message); }
}
export const fail = (message: string, status = 400): never => { throw new TimeError(status, message); };
export function instant(value: unknown): string {
if (typeof value !== 'string' || !/T.*(?:Z|[+-]\d\d:\d\d)$/.test(value) || !Number.isFinite(Date.parse(value)))
fail('Zeitpunkt mit Zeitzone erforderlich.');
dateOnly((value as string).slice(0, 10));
return new Date(value as string).toISOString();
}
export function dateOnly(value: unknown): string {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value)
fail('Ungültiges Datum.');
return value as string;
}
export function midnight(date: string, zone = 'Europe/Berlin') { return dayjs.tz(dateOnly(date), zone).toISOString(); }
export function nextDay(date: string) { return dayjs.utc(dateOnly(date)).add(1, 'day').format('YYYY-MM-DD'); }
export function range(from: string, to: string, zone = 'Europe/Berlin') {
dateOnly(from);
dateOnly(to);
if (from > to || dayjs.utc(to).diff(dayjs.utc(from), 'day') > 3660)
fail('Zeitraum muss zwischen 1 und 3661 Tagen liegen.');
return { started_at: midnight(from, zone), ended_at: midnight(nextDay(to), zone) };
}
const end = (p: Period) => p.ended_at ? Date.parse(p.ended_at) : Infinity;
export const overlaps = (a: Period, b: Period) => Date.parse(a.started_at) < end(b) && Date.parse(b.started_at) < end(a);
export const minutes = (p: Period, window: Period) => Math.max(0, Math.min(end(p), end(window)) - Math.max(Date.parse(p.started_at), Date.parse(window.started_at))) / 60000;
export function normalizeSpan(input: any): Span {
if (!input || !TYPES.includes(input.type))
fail('Ungültige Zeitart.');
const zone = input.timezone || 'Europe/Berlin';
try {
new Intl.DateTimeFormat('de', { timeZone: zone });
}
catch {
fail('Ungültige Zeitzone.');
}
const allDay = input.date_from != null || input.date_to != null;
const span: Span = {
type: input.type, description: String(input.description || '').slice(0, 10000), timezone: zone,
started_at: allDay ? midnight(input.date_from, zone) : instant(input.started_at),
ended_at: allDay ? midnight(input.date_to, zone) : input.ended_at == null ? null : instant(input.ended_at),
date_from: allDay ? dateOnly(input.date_from) : null, date_to: allDay ? dateOnly(input.date_to) : null,
status: 'factual', breaks: [], projects: [],
};
if (allDay && (span.type === 'work' || span.date_to! <= span.date_from!))
fail('Ungültiger ganztägiger Zeitraum; Ende ist exklusiv.');
if (span.ended_at && end(span) <= Date.parse(span.started_at))
fail('Ende muss nach Beginn liegen.');
if (!Array.isArray(input.breaks || []) || !Array.isArray(input.projects || []))
fail('Ungültige Teilzeiträume.');
span.breaks = (input.breaks || []).map((p: any) => ({ started_at: instant(p.started_at), ended_at: p.ended_at == null ? null : instant(p.ended_at) }));
span.projects = (input.projects || []).map((p: any) => {
if (!Number.isSafeInteger(Number(p.project_id)) || Number(p.project_id) <= 0)
fail('Projekt erforderlich.');
return { started_at: instant(p.started_at), ended_at: instant(p.ended_at), project_id: Number(p.project_id), notes: String(p.notes || '').slice(0, 10000) };
});
if (span.type !== 'work' && (span.breaks.length || span.projects.length))
fail('Pausen und Projekte benötigen eine Anwesenheit.');
for (const parts of [span.breaks, span.projects]) {
if (parts.length > 200)
fail('Zu viele Teilzeiträume.');
parts.sort((a, b) => Date.parse(a.started_at) - Date.parse(b.started_at));
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (end(p) <= Date.parse(p.started_at) || Date.parse(p.started_at) < Date.parse(span.started_at) || end(p) > end(span))
fail('Teilzeitraum muss innerhalb der Anwesenheit liegen.');
if (i && overlaps(parts[i - 1], p))
fail('Teilzeiträume dürfen sich nicht überschneiden.');
}
}
for (const project of span.projects)
if (span.breaks.some(p => overlaps(p, project)))
fail('Projektzeit darf keine Pause enthalten.');
return span;
}
export function transition(span: Span, action: string, reason = '') {
if (!span.ended_at)
fail('Laufende Einträge zuerst beenden.', 409);
if (action === 'submit' && ['factual', 'rejected'].includes(span.status))
return 'submitted';
if (action === 'approve' && span.status === 'submitted')
return 'approved';
if (action === 'reject' && span.status === 'submitted' && reason.trim())
return 'rejected';
fail('Ungültiger Statuswechsel oder fehlender Ablehnungsgrund.', 409);
}
export function evaluate(spans: Span[], profile: any, holidayDates: string[], from: string, to: string, zone = 'Europe/Berlin') {
const window = range(from, to, zone);
const sum = { timeSpanWorkingMinutes: 0, sumWorkingMinutesSubmitted: 0, sumWorkingMinutesApproved: 0,
sumWorkingMinutesRecreationDays: 0, sumRecreationDays: 0, sumWorkingMinutesVacationDays: 0, sumVacationDays: 0,
sumWorkingMinutesSickDays: 0, sumSickDays: 0, sumOvertimeCompensationMinutes: 0, sumOtherMinutes: 0,
presenceMinutes: 0, breakMinutes: 0, saldoApproved: 0, saldoSubmitted: 0 };
for (const s of spans.filter(s => s.type === 'work' && s.ended_at)) {
const gross = minutes(s, window), breaks = s.breaks.reduce((n, p) => n + minutes(p, window), 0), net = gross - breaks;
sum.presenceMinutes += gross;
sum.breakMinutes += breaks;
if (['submitted', 'approved'].includes(s.status))
sum.sumWorkingMinutesSubmitted += net;
if (s.status === 'approved')
sum.sumWorkingMinutesApproved += net;
}
const holidays = new Set(holidayDates);
for (let date = from; date <= to; date = nextDay(date)) {
const day = range(date, date, zone), weekday = dayjs.utc(date).day();
const target = Math.max(0, Number(profile.weekly_regular_working_hours?.[weekday] || 0)) * 60;
sum.timeSpanWorkingMinutes += target;
// Credits are capped per day; holidays take precedence over overlapping absence credits.
let remaining = target;
const approved = spans.filter(s => s.type !== 'work' && s.status === 'approved' && s.ended_at && overlaps(s, day));
if ((profile.recreation_days_compensation && holidays.has(date)) || approved.some(s => s.type === 'holiday')) {
sum.sumWorkingMinutesRecreationDays += target;
sum.sumRecreationDays += target > 0 ? 1 : 0;
remaining = 0;
}
for (const type of ['vacation', 'sick', 'overtime_compensation', 'other']) {
for (const s of approved.filter(s => s.type === type)) {
const credit = Math.min(remaining, s.date_from ? target : minutes(s, day));
if (type === 'vacation') {
sum.sumWorkingMinutesVacationDays += credit;
sum.sumVacationDays += target ? credit / target : 0;
}
if (type === 'sick') {
sum.sumWorkingMinutesSickDays += credit;
sum.sumSickDays += target ? credit / target : 0;
}
// Freizeitausgleich consumes the balance through the regular daily target, without a credit.
if (type === 'overtime_compensation')
sum.sumOvertimeCompensationMinutes += credit;
if (type === 'other')
sum.sumOtherMinutes += credit;
remaining -= credit;
}
}
}
const credit = sum.sumWorkingMinutesRecreationDays + sum.sumWorkingMinutesVacationDays + sum.sumWorkingMinutesSickDays;
sum.saldoApproved = sum.sumWorkingMinutesApproved + credit - sum.timeSpanWorkingMinutes;
sum.saldoSubmitted = sum.sumWorkingMinutesSubmitted + credit - sum.timeSpanWorkingMinutes;
return sum;
}

View File

@@ -1,146 +0,0 @@
import { sql } from 'drizzle-orm';
import { Actor, authorize, digest, rows, TimeService } from './service';
import { fail, normalizeSpan, overlaps, Span } from './domain';
type Candidate = {
span: Span;
origins: {
kind: string;
id: string;
}[];
};
/** Conservative replay: never invent missing ends or silently discard ambiguous points. */
export function previewLegacy(events: any[], entries: any[], connects: any[]) {
const issues: string[] = [], candidates: Candidate[] = [];
const invalidated = new Set(events.map(e => e.invalidates_event_id).filter(Boolean));
const valid = events.filter(e => !invalidated.has(e.id) && e.eventtype !== 'invalidated');
const states = new Map<string, string>();
for (const e of [...valid].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id))) {
if (['submitted', 'approved', 'rejected'].includes(e.eventtype) && e.related_event_id)
states.set(e.related_event_id, e.eventtype);
}
const points = valid.filter(e => !['submitted', 'approved', 'rejected'].includes(e.eventtype))
.sort((a, b) => Date.parse(a.eventtime) - Date.parse(b.eventtime) || String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id));
let current: any = null, origins: Candidate['origins'] = [];
const close = () => {
if (!current)
return;
const statusSet = new Set(origins.map(o => states.get(o.id) || 'factual'));
if (statusSet.size > 1)
issues.push(`Uneinheitliche Freigaben ab ${current.started_at}; vor Übernahme vereinheitlichen.`);
current.status = statusSet.size === 1 ? [...statusSet][0] : 'factual';
if (!current.ended_at)
issues.push(`Offener Alt-Eintrag ab ${current.started_at}; zuerst abschließen.`);
try {
const normalized = normalizeSpan(current);
normalized.status = current.status;
candidates.push({ span: normalized, origins: [...origins] });
}
catch (e: any) {
issues.push(`${current.started_at}: ${e.message}`);
}
current = null;
origins = [];
};
for (const e of points) {
const type = e.eventtype === 'START' ? 'work_start' : e.eventtype === 'STOP' || e.eventtype === 'auto_stop' ? 'work_end' : e.eventtype;
const time = new Date(e.eventtime).toISOString();
if (type === 'pause_start' || type === 'pause_end') {
if (!current || current.type !== 'work') {
issues.push(`Pause ohne Anwesenheit: ${e.id}`);
continue;
}
const open = current.breaks.find((p: any) => !p.ended_at);
if (type === 'pause_start' && !open)
current.breaks.push({ started_at: time, ended_at: null });
else if (type === 'pause_end' && open)
open.ended_at = time;
else
issues.push(`Widersprüchliche Pause: ${e.id}`);
origins.push({ kind: 'event', id: e.id });
continue;
}
if (type.endsWith('_start')) {
if (current) {
issues.push(`Neuer Beginn vor vorherigem Ende: ${e.id}`);
close();
}
current = { type: type.slice(0, -6), started_at: time, ended_at: null, description: e.metadata?.description || e.payload?.description || '', breaks: [], projects: [], timezone: 'Europe/Berlin' };
origins = [{ kind: 'event', id: e.id }];
if (e.metadata?.project)
current.projects.push({ project_id: Number(e.metadata.project), started_at: time, ended_at: null, notes: current.description });
}
else if (type.endsWith('_end')) {
if (!current || current.type !== type.slice(0, -4)) {
issues.push(`Ende ohne passenden Beginn: ${e.id}`);
continue;
}
current.ended_at = time;
for (const p of current.breaks)
if (!p.ended_at)
p.ended_at = time;
for (const p of current.projects)
p.ended_at = time;
origins.push({ kind: 'event', id: e.id });
close();
}
else
issues.push(`Unbekanntes Ereignis ${e.eventtype}: ${e.id}`);
}
close();
for (const e of entries.filter(e => !e.archived)) {
try {
const span = normalizeSpan({ ...e, ended_at: e.stopped_at, timezone: 'Europe/Berlin', breaks: [], projects: connects.filter(c => c.time_entry_id === e.id).map(c => ({ ...c, ended_at: c.stopped_at })) });
span.status = e.state === 'draft' ? 'factual' : e.state;
if (!span.ended_at)
issues.push(`Offener Alt-Eintrag ${e.id}; zuerst abschließen.`);
candidates.push({ span, origins: [{ kind: 'entry', id: e.id }] });
}
catch (err: any) {
issues.push(`Alt-Eintrag ${e.id}: ${err.message}`);
}
}
candidates.sort((a, b) => Date.parse(a.span.started_at) - Date.parse(b.span.started_at));
for (let i = 1; i < candidates.length; i++)
if (overlaps(candidates[i - 1].span, candidates[i].span))
issues.push(`Überlappende Alt-Einträge ab ${candidates[i].span.started_at}.`);
const totalMinutes = candidates.reduce((n, { span: s }) => n + (s.type === 'work' && s.ended_at ? (Date.parse(s.ended_at) - Date.parse(s.started_at) - s.breaks.reduce((v, p) => v + (p.ended_at ? Date.parse(p.ended_at) - Date.parse(p.started_at) : 0), 0)) / 60000 : 0), 0);
return { candidates, issues, totalMinutes, count: candidates.length, fingerprint: digest({ events, entries, connects }) };
}
export class TimeMigration {
constructor(public service: TimeService) { }
async preview(a: Actor, user: string, db = this.service.db) {
authorize(a, user, 'activate');
const events = await rows(db, sql `SELECT * FROM staff_time_events WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid ORDER BY event_time,id`);
// Raw SQL column names differ from the historical Drizzle property names.
const mapped = events.map(e => ({ ...e, eventtype: e.event_type, eventtime: e.event_time }));
const entries = await rows(db, sql `SELECT * FROM staff_time_entries WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid ORDER BY started_at,id`);
const connects = await rows(db, sql `SELECT c.* FROM staff_time_entry_connects c JOIN staff_time_entries e ON e.id=c.time_entry_id WHERE e.tenant_id=${a.tenant} AND e.user_id=${user}::uuid ORDER BY c.started_at,c.id`);
const result = previewLegacy(mapped, entries, connects);
for (const { span } of result.candidates)
for (const project of span.projects) {
if (!(await rows(db, sql `SELECT id FROM projects WHERE id=${project.project_id} AND tenant=${a.tenant}`)).length)
result.issues.push(`Projekt ${project.project_id} fehlt im Mandanten.`);
}
return { ...result, active: await this.service.active(a, user, db) };
}
async activate(a: Actor, user: string, fingerprint: string) {
authorize(a, user, 'activate');
return this.service.db.transaction(async (tx: any) => {
await this.service.lock(tx, a, user);
if (await this.service.active(a, user, tx))
return { active: true, alreadyActivated: true };
const preview = await this.preview(a, user, tx);
if (preview.fingerprint !== fingerprint)
fail('Altdaten wurden geändert. Vorschau erneut laden.', 409);
if (preview.issues.length)
fail('Altdaten enthalten Konflikte. Vor Aktivierung korrigieren.', 409);
for (const c of preview.candidates) {
const created = await this.service.save(tx, a, user, c.span, null, 'migration', 'Übernahme aus bisheriger Zeiterfassung', 'migration');
for (const origin of c.origins)
await tx.execute(sql `INSERT INTO staff_time_span_origins(tenant_id,source_kind,source_id,span_id) VALUES(${a.tenant},${origin.kind},${origin.id}::uuid,${created.id}::uuid)`);
}
await tx.execute(sql `INSERT INTO staff_time_v2_accounts(tenant_id,user_id,activated_by) VALUES(${a.tenant},${user}::uuid,${a.user}::uuid)`);
return { active: true, count: preview.count, totalMinutes: preview.totalMinutes };
});
}
}

View File

@@ -1,189 +0,0 @@
import { createHash } from 'node:crypto';
import { sql, SQL } from 'drizzle-orm';
import { fail, normalizeSpan, transition, range, evaluate, Span, instant, overlaps } from './domain';
export type Actor = {
tenant: number;
user: string;
admin?: boolean;
permissions?: string[];
trusted?: boolean;
};
export const can = (a: Actor, permission: string) => Boolean(a.admin || a.trusted || a.permissions?.includes(permission));
export function authorize(a: Actor, user: string, operation = 'read') {
if (!a.tenant || !a.user || !user)
fail('Anmeldung und Mandant erforderlich.', 403);
if (operation === 'approve' || operation === 'activate') {
if (!can(a, `staff.time.${operation}`))
fail('Keine Berechtigung für diese Aktion.', 403);
}
else if (user !== a.user && !can(a, `staff.time.${operation}_all`) && !(operation === 'read' && ['write_all', 'approve', 'activate'].some(p => can(a, `staff.time.${p}`))))
fail('Keine Berechtigung für fremde Zeiten.', 403);
}
export async function rows(db: any, query: SQL): Promise<any[]> {
const result = await db.execute(query);
const normalize = (value: any, key = ''): any => {
if (value instanceof Date)
return value.toISOString();
if (Array.isArray(value))
return value.map(v => normalize(v));
if (value && typeof value === 'object')
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, normalize(v, k)]));
if (typeof value === 'string' && (key.endsWith('_at') || key === 'event_time') && /^\d{4}-\d{2}-\d{2}[ T]/.test(value))
return new Date(value).toISOString();
return value;
};
return normalize(result.rows);
}
export const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
export class TimeService {
constructor(public db: any) { }
async lock(tx: any, a: Actor, user: string) {
await tx.execute(sql `SELECT pg_advisory_xact_lock(hashtextextended(${`staff-time:${a.tenant}:${user}`},0))`);
const member = await rows(tx, sql `SELECT user_id FROM auth_profiles WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid LIMIT 1`);
if (!member.length)
fail('Mitarbeiter im Mandanten nicht gefunden.', 404);
}
async active(a: Actor, user: string, db = this.db) {
return (await rows(db, sql `SELECT * FROM staff_time_v2_accounts WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid`))[0] || null;
}
async list(a: Actor, user: string, from?: string, to?: string, db = this.db): Promise<Span[]> {
authorize(a, user);
const w = from && to ? range(from, to) : null;
const filter = w ? sql `AND s.started_at < ${w.ended_at}::timestamptz AND (s.ended_at IS NULL OR s.ended_at > ${w.started_at}::timestamptz)` : sql ``;
return rows(db, sql `SELECT s.*,
COALESCE((SELECT jsonb_agg(b ORDER BY b.started_at) FROM staff_time_span_breaks b WHERE b.span_id=s.id),'[]'::jsonb) AS breaks,
COALESCE((SELECT jsonb_agg(p ORDER BY p.started_at) FROM staff_time_span_projects p WHERE p.span_id=s.id),'[]'::jsonb) AS projects
FROM staff_time_spans s WHERE s.tenant_id=${a.tenant} AND s.user_id=${user}::uuid ${filter} ORDER BY s.started_at DESC,s.id`);
}
async current(a: Actor, user: string, db = this.db): Promise<Span | null> {
authorize(a, user);
const [open] = await rows(db, sql `SELECT id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND ended_at IS NULL LIMIT 1`);
return open ? this.get(a, open.id, db) : null;
}
async get(a: Actor, id: string, db = this.db): Promise<Span> {
const [s] = await rows(db, sql `SELECT * FROM staff_time_spans WHERE id=${id}::uuid AND tenant_id=${a.tenant}`);
if (!s)
fail('Zeiteintrag nicht gefunden.', 404);
authorize(a, s.user_id);
s.breaks = await rows(db, sql `SELECT * FROM staff_time_span_breaks WHERE span_id=${id}::uuid ORDER BY started_at`);
s.projects = await rows(db, sql `SELECT * FROM staff_time_span_projects WHERE span_id=${id}::uuid ORDER BY started_at`);
return s;
}
async history(a: Actor, id: string) {
await this.get(a, id);
return rows(this.db, sql `SELECT * FROM staff_time_span_history WHERE span_id=${id}::uuid ORDER BY created_at,id`);
}
async save(tx: any, a: Actor, user: string, data: any, before: Span | null, action: string, reason: string, source: string): Promise<Span> {
const s = normalizeSpan(data);
s.status = data.status || 'factual';
const conflict = await rows(tx, sql `SELECT id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid
AND (${before?.id || null}::uuid IS NULL OR id <> ${before?.id || null}::uuid)
AND started_at < COALESCE(${s.ended_at}::timestamptz,'infinity'::timestamptz)
AND COALESCE(ended_at,'infinity'::timestamptz) > ${s.started_at}::timestamptz LIMIT 1`);
if (conflict.length)
fail('Der Zeitraum überschneidet sich mit einem bestehenden Eintrag.', 409);
for (const p of s.projects) {
if (!(await rows(tx, sql `SELECT id FROM projects WHERE id=${p.project_id} AND tenant=${a.tenant}`)).length)
fail('Projekt gehört nicht zum Mandanten.', 400);
}
let id = before?.id;
if (id) {
await tx.execute(sql `UPDATE staff_time_spans SET type=${s.type},started_at=${s.started_at}::timestamptz,ended_at=${s.ended_at}::timestamptz,
date_from=${s.date_from}::date,date_to=${s.date_to}::date,timezone=${s.timezone},description=${s.description},status=${s.status},version=version+1,updated_at=now() WHERE id=${id}::uuid`);
await tx.execute(sql `DELETE FROM staff_time_span_breaks WHERE span_id=${id}::uuid`);
await tx.execute(sql `DELETE FROM staff_time_span_projects WHERE span_id=${id}::uuid`);
}
else {
const [created] = await rows(tx, sql `INSERT INTO staff_time_spans(tenant_id,user_id,type,started_at,ended_at,date_from,date_to,timezone,description,source,status)
VALUES(${a.tenant},${user}::uuid,${s.type},${s.started_at}::timestamptz,${s.ended_at}::timestamptz,${s.date_from}::date,${s.date_to}::date,${s.timezone},${s.description},${source},${s.status}) RETURNING id`);
id = created.id;
}
for (const p of s.breaks)
await tx.execute(sql `INSERT INTO staff_time_span_breaks(span_id,started_at,ended_at) VALUES(${id}::uuid,${p.started_at}::timestamptz,${p.ended_at}::timestamptz)`);
for (const p of s.projects)
await tx.execute(sql `INSERT INTO staff_time_span_projects(span_id,project_id,started_at,ended_at,notes) VALUES(${id}::uuid,${p.project_id},${p.started_at}::timestamptz,${p.ended_at}::timestamptz,${p.notes})`);
const after = await this.get({ ...a, trusted: true }, id!, tx);
await tx.execute(sql `INSERT INTO staff_time_span_history(span_id,actor_id,action,reason,before_data,after_data)
VALUES(${id}::uuid,${a.user}::uuid,${action},${reason},${before ? JSON.stringify(before) : null}::jsonb,${JSON.stringify(after)}::jsonb)`);
return after;
}
async mutate(a: Actor, user: string, requestId: string, command: any, source = 'web') {
const approval = ['approve', 'reject'].includes(command.action);
authorize(a, user, approval ? 'approve' : 'write');
if (typeof requestId !== 'string' || requestId.length < 8 || requestId.length > 200)
fail('Eindeutige Anfrage-ID erforderlich.');
const fingerprint = digest({ command, actor: a.user, source });
return this.db.transaction(async (tx: any) => {
await this.lock(tx, a, user);
if (!await this.active(a, user, tx))
fail('Zeiterfassung 2.0 zuerst mit Datenvorschau aktivieren.', 409);
const [previous] = await rows(tx, sql `SELECT * FROM staff_time_v2_requests WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND request_id=${requestId}`);
if (previous) {
if (previous.fingerprint !== fingerprint)
fail('Anfrage-ID wurde bereits für andere Daten verwendet.', 409);
return previous.response;
}
const action = command.action;
if (['create', 'start'].includes(action) && command.id)
fail('Neue Einträge dürfen keine bestehende ID ersetzen.');
let before: Span | null = null, data: any;
if (command.id) {
before = await this.get({ ...a, trusted: true }, command.id, tx);
if (before.user_id !== user)
fail('Eintrag gehört zu anderem Mitarbeiter.', 403);
if (command.version !== before.version)
fail('Eintrag wurde zwischenzeitlich geändert. Bitte neu laden.', 409);
}
if (action === 'create')
data = { ...command.data, status: 'factual' };
else if (action === 'start') {
data = { type: 'work', started_at: instant(command.at), ended_at: null, description: command.description || '' };
}
else if (['stop', 'pause', 'resume'].includes(action)) {
if (!before || before.type !== 'work' || before.ended_at)
fail('Keine passende laufende Anwesenheit.', 409);
data = { ...before, breaks: before.breaks.map(p => ({ ...p })) };
const at = instant(command.at), open = data.breaks.find((p: any) => !p.ended_at);
if (action === 'pause') {
if (open)
fail('Pause läuft bereits.', 409);
data.breaks.push({ started_at: at, ended_at: null });
}
else {
if (action === 'resume' && !open)
fail('Keine laufende Pause.', 409);
if (open)
open.ended_at = at;
if (action === 'stop')
data.ended_at = at;
}
}
else if (action === 'update') {
if (!before)
fail('Eintrag erforderlich.');
if (!String(command.reason || '').trim())
fail('Korrekturgrund erforderlich.');
data = { ...command.data, status: 'factual' };
}
else if (['submit', 'approve', 'reject'].includes(action)) {
if (!before)
fail('Eintrag erforderlich.');
data = { ...before, status: transition(before, action, command.reason) };
}
else
fail('Unbekannte Aktion.');
const response = await this.save(tx, a, user, data, before, action, String(command.reason || ''), source);
await tx.execute(sql `INSERT INTO staff_time_v2_requests(tenant_id,user_id,request_id,fingerprint,response)
VALUES(${a.tenant},${user}::uuid,${requestId},${fingerprint},${JSON.stringify(response)}::jsonb)`);
return response;
});
}
async evaluation(a: Actor, user: string, from: string, to: string) {
const spans = await this.list(a, user, from, to);
const [profile] = await rows(this.db, sql `SELECT * FROM auth_profiles WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid LIMIT 1`);
if (!profile)
fail('Mitarbeiterprofil fehlt.', 404);
const holidays = await rows(this.db, sql `SELECT DISTINCT date FROM holidays WHERE state_code IN ('DE',${profile.state_code || 'DE'}) AND date BETWEEN ${from}::date AND ${to}::date`);
return { userId: user, spans, summary: { ...evaluate(spans, profile, holidays.map(h => h.date), from, to), from, to, user_id: user, tenant_id: a.tenant } };
}
}

View File

@@ -1,5 +1,3 @@
import { TimeService } from "../time-v2/service";
import { legacySpans } from "../time-v2/compatibility";
import { FastifyInstance } from "fastify";
import {and, eq, gte, lte, asc, inArray} from "drizzle-orm";
import {
@@ -15,14 +13,6 @@ export async function generateTimesEvaluation(
startDateInput: string,
endDateInput: string
) {
const service = new TimeService(server.db);
const actor = { tenant: tenant_id, user: user_id };
if (await service.active(actor, user_id)) {
const result = await service.evaluation(actor, user_id, startDateInput, endDateInput);
return { ...result.summary, sumWorkingMinutesEingereicht: result.summary.sumWorkingMinutesSubmitted,
saldo: result.summary.saldoApproved, saldoInOfficial: result.summary.saldoSubmitted,
times: legacySpans(result.spans).map(s => ({ ...s, started_at: s.startedAt, stopped_at: s.endedAt, state: s.status })) };
}
const startDate = server.dayjs(startDateInput);
const endDate = server.dayjs(endDateInput);

View File

@@ -1,7 +1,7 @@
import { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import { secrets } from "../utils/secrets";
import { and, eq, isNull } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { authUsers, m2mApiKeys } from "../../db/schema";
import { createHash } from "node:crypto";
@@ -44,8 +44,7 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and(
eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true),
isNull(authUsers.archived_at)
eq(m2mApiKeys.active, true)
))
.limit(1)
@@ -71,7 +70,7 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string
const users = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(and(eq(authUsers.id, userId), isNull(authUsers.archived_at)))
.where(eq(authUsers.id, userId))
.limit(1)
if (!users[0]) {

View File

@@ -12,7 +12,7 @@ import {
tenants,
} from "../../db/schema"
import { eq, and, inArray, isNull } from "drizzle-orm"
import { eq, and, inArray } from "drizzle-orm"
export default fp(async (server: FastifyInstance) => {
const hashApiKey = (apiKey: string) =>
@@ -56,8 +56,7 @@ export default fp(async (server: FastifyInstance) => {
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and(
eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true),
isNull(authUsers.archived_at)
eq(m2mApiKeys.active, true)
))
.limit(1)
@@ -142,16 +141,22 @@ export default fp(async (server: FastifyInstance) => {
return reply.code(401).send({ error: "Invalid token" })
}
// Check current account state even when a JWT carries cached admin claims.
const [currentUser] = await server.db
.select({ is_admin: authUsers.is_admin })
.from(authUsers)
.where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at)))
.limit(1)
if (!currentUser) {
return reply.code(401).send({ error: "Invalid or expired token" })
// Payload an Request hängen
req.user = payload
if (typeof payload.is_admin === "boolean") {
req.user.is_admin = payload.is_admin
} else {
const [currentUser] = await server.db
.select({
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.id, payload.user_id))
.limit(1)
req.user.is_admin = Boolean(currentUser?.is_admin)
}
req.user = { ...payload, is_admin: Boolean(currentUser.is_admin) }
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
if (!req.user.tenant_id) {

View File

@@ -33,12 +33,8 @@ import { requireSystemAdmin } from "../utils/requireSystemAdmin";
import { buildSystemStatus } from "../modules/system-status.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import adminUserArchiveRoutes from "./adminUserArchive";
import { getPortalLoginUrl } from "../utils/portalUrl";
export default async function adminRoutes(server: FastifyInstance) {
await adminUserArchiveRoutes(server);
await server.register(multipart, {
limits: { fileSize: 1024 * 1024 * 1024 }
});
@@ -605,7 +601,6 @@ export default async function adminRoutes(server: FastifyInstance) {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true";
const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([
server.db
.select({
@@ -625,9 +620,8 @@ export default async function adminRoutes(server: FastifyInstance) {
multiTenant: authUsers.multiTenant,
must_change_password: authUsers.must_change_password,
is_admin: authUsers.is_admin,
archived_at: authUsers.archived_at,
})
.from(authUsers).where(includeArchived ? undefined : isNull(authUsers.archived_at)),
.from(authUsers),
server.db
.select({
id: authProfiles.id,
@@ -638,7 +632,6 @@ export default async function adminRoutes(server: FastifyInstance) {
full_name: authProfiles.full_name,
email: authProfiles.email,
active: authProfiles.active,
archived_at: authProfiles.archived_at,
})
.from(authProfiles),
server.db
@@ -687,17 +680,16 @@ export default async function adminRoutes(server: FastifyInstance) {
};
});
const activeUserIds = new Set(userRows.filter((user) => !user.archived_at).map((user) => user.id));
const tenantsWithCounts = tenantRows.map((tenant) => ({
...tenant,
user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id && activeUserIds.has(membership.user_id)).length,
user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
}));
return {
users,
tenants: tenantsWithCounts,
roles: roleRows,
unassignedProfiles: profileRows.filter((profile) => !profile.user_id && !profile.archived_at && profile.active),
unassignedProfiles: profileRows.filter((profile) => !profile.user_id),
memberships: uniqueMembershipRows,
roleAssignments: roleAssignmentRows,
};
@@ -816,7 +808,7 @@ export default async function adminRoutes(server: FastifyInstance) {
email: authProfiles.email,
})
.from(authProfiles)
.where(and(eq(authProfiles.id, profileId), isNull(authProfiles.archived_at)))
.where(eq(authProfiles.id, profileId))
.limit(1);
if (!profile) {
@@ -939,17 +931,6 @@ export default async function adminRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: "Customer has no email address" });
}
let portalUrl: string;
try {
portalUrl = getPortalLoginUrl({
portalDomain: tenantRecord?.portalDomain,
publicUrl: process.env.FEDEO_PUBLIC_URL,
apiBaseUrl: secrets.API_BASE_URL,
});
} catch (err: any) {
return reply.code(503).send({ error: err.message });
}
const generatedPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(generatedPassword);
@@ -958,16 +939,11 @@ export default async function adminRoutes(server: FastifyInstance) {
id: authUsers.id,
email: authUsers.email,
is_admin: authUsers.is_admin,
archived_at: authUsers.archived_at,
})
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
if (existingUser?.archived_at) {
return reply.code(409).send({ error: "Dieses Benutzerkonto ist dauerhaft archiviert." });
}
const derivedName = deriveNameFromEmail(email);
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
@@ -1110,6 +1086,8 @@ export default async function adminRoutes(server: FastifyInstance) {
});
}
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
const mailResult = await sendMail(
email,
`FEDEO | Einladung ins Kundenportal`,
@@ -1119,8 +1097,7 @@ export default async function adminRoutes(server: FastifyInstance) {
<p><strong>E-Mail:</strong> ${email}</p>
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
<p><a href="${portalUrl}">Zum Kundenportal anmelden</a></p>
<p>Ihre Login-Adresse: ${portalUrl}</p>
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
`
);
@@ -1199,12 +1176,6 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return;
const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = req.body as {
email?: string;
multiTenant?: boolean;
@@ -1254,12 +1225,6 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return;
const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = (req.body || {}) as { password?: string };
const requestedPassword = body.password?.trim();
@@ -1660,12 +1625,6 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return;
const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = req.body as {
tenant_ids?: number[];
role_assignments?: { tenant_id: number; role_id: string }[];
@@ -1742,9 +1701,7 @@ export default async function adminRoutes(server: FastifyInstance) {
.where(
and(
inArray(authProfiles.tenant_id, tenantIds),
isNull(authProfiles.user_id),
isNull(authProfiles.archived_at),
eq(authProfiles.active, true)
isNull(authProfiles.user_id)
)
)
: [];
@@ -1867,10 +1824,6 @@ export default async function adminRoutes(server: FastifyInstance) {
});
}
const [targetUser] = await server.db.select({ id: authUsers.id })
.from(authUsers).where(and(eq(authUsers.id, body.user_id), isNull(authUsers.archived_at))).limit(1);
if (!targetUser) return reply.code(404).send({ error: "Aktiver Benutzer nicht gefunden." });
const mode = body.mode ?? "multi";
// ----------------------------

View File

@@ -1,51 +0,0 @@
import type { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { authProfiles, authRefreshTokens, authUsers, m2mApiKeys } from "../../db/schema"
import { requireSystemAdmin } from "../utils/requireSystemAdmin"
export default async function adminUserArchiveRoutes(server: FastifyInstance) {
server.post("/admin/users/:user_id/archive", {
schema: {
params: {
type: "object",
required: ["user_id"],
properties: { user_id: { type: "string", format: "uuid" } },
},
},
}, async (req, reply) => {
const currentUser = await requireSystemAdmin(server, req, reply)
if (!currentUser) return
const { user_id } = req.params as { user_id: string }
if (user_id === currentUser.id) {
return reply.code(409).send({ error: "Das eigene Benutzerkonto kann nicht archiviert werden." })
}
const user = await server.db.transaction(async (tx) => {
// Serialize repeated archive requests and keep the original archive date.
const [target] = await tx.select({ id: authUsers.id, archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1).for("update")
if (!target) return null
const archivedAt = target.archived_at || new Date()
const [archivedUser] = await tx.update(authUsers)
.set({ archived_at: archivedAt, updatedAt: archivedAt })
.where(eq(authUsers.id, user_id))
.returning({ id: authUsers.id, archived_at: authUsers.archived_at })
// Keep links intact so existing documents and time records retain their authors.
await tx.update(authProfiles)
.set({ active: false, archived_at: archivedAt, token_id: null, calendar_subscription_token: null })
.where(eq(authProfiles.user_id, user_id))
await tx.update(authRefreshTokens).set({ revokedAt: archivedAt })
.where(eq(authRefreshTokens.userId, user_id))
await tx.update(m2mApiKeys).set({ active: false, updatedAt: archivedAt })
.where(eq(m2mApiKeys.userId, user_id))
return archivedUser
})
if (!user) return reply.code(404).send({ error: "Benutzer nicht gefunden." })
return { user }
})
}

View File

@@ -6,8 +6,7 @@ import { sendMail } from "../../utils/mailer";
import { authUsers } from "../../../db/schema";
import { authTenantUsers } from "../../../db/schema";
import { tenants } from "../../../db/schema";
import { and, eq, isNull } from "drizzle-orm";
import { resolveLoginTenant } from "../../utils/loginTenant";
import { and, eq } from "drizzle-orm";
import {
createAccessToken,
issueRefreshToken,
@@ -112,7 +111,7 @@ export default async function authRoutes(server: FastifyInstance) {
const [found] = await server.db
.select()
.from(authUsers)
.where(and(eq(authUsers.email, body.email.toLowerCase()), isNull(authUsers.archived_at)))
.where(eq(authUsers.email, body.email.toLowerCase()))
.limit(1);
if (!found) {
@@ -128,7 +127,7 @@ export default async function authRoutes(server: FastifyInstance) {
return reply.code(401).send({ error: "Invalid credentials" });
}
const tenantId = await resolveLoginTenant(server, user.id, req.tenant?.id ? Number(req.tenant.id) : null);
const tenantId = req.tenant?.id ? Number(req.tenant.id) : null;
const token = createAccessToken(user, tenantId);
const refreshToken = await issueRefreshToken(server, user.id, tenantId);
@@ -216,7 +215,7 @@ export default async function authRoutes(server: FastifyInstance) {
mustChangePassword: authUsers.must_change_password,
})
.from(authUsers)
.where(and(eq(authUsers.email, email.toLowerCase()), isNull(authUsers.archived_at)))
.where(eq(authUsers.email, email.toLowerCase()))
.limit(1);
if (!user) {

View File

@@ -58,7 +58,6 @@ export default async function meRoutes(server: FastifyInstance) {
features: tenants.features,
extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo,
portalConfig: tenants.portalConfig,
numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
taxEvaluationPeriod: tenants.taxEvaluationPeriod,

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify"
import { eq, and, isNull } from "drizzle-orm"
import { eq, and } from "drizzle-orm"
import {
authUsers,
@@ -83,8 +83,7 @@ export default async function userRoutes(server: FastifyInstance) {
.where(
and(
eq(authProfiles.user_id, id),
eq(authProfiles.tenant_id, req.user.tenant_id),
isNull(authProfiles.archived_at)
eq(authProfiles.tenant_id, req.user.tenant_id)
)
)
@@ -100,7 +99,6 @@ export default async function userRoutes(server: FastifyInstance) {
}
const updateData: any = { ...data }
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete updateData[key]
// bekannte Date-Felder prüfen
if (data.entry_date !== undefined)
@@ -118,10 +116,9 @@ export default async function userRoutes(server: FastifyInstance) {
const [updatedProfile] = await server.db
.update(authProfiles)
.set(updateData)
.where(and(eq(authProfiles.id, profile.id), isNull(authProfiles.archived_at)))
.where(eq(authProfiles.id, profile.id))
.returning()
if (!updatedProfile) return reply.code(404).send({ error: "Profile not found in tenant" })
return { profile: updatedProfile }
} catch (err: any) {

View File

@@ -10,7 +10,6 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { centralServicesClient } from "../modules/push-server.client"
import { findBankInstitutionByBic } from "../modules/banking-institution"
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
import {
bankrequisitions,
@@ -34,7 +33,9 @@ import {
desc,
} from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) {
const CASHBOOK_BANK_ID = "fedeo-cashbook"
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
const ContraCustomers = aliasedTable(customers, "contra_customers")
const ContraVendors = aliasedTable(vendors, "contra_vendors")

View File

@@ -111,9 +111,6 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll
if (action === "room") return service.room(ctx.principal, id)
if (action === "messages") return service.messages(ctx.principal, id, input)
if (action === "send") return service.send(ctx.principal, id, input)
if (action === "image") return service.image(ctx.principal, id, input)
if (action === "reactions") return service.reactions(ctx.principal, id, input)
if (action === "react") return service.react(ctx.principal, id, input)
if (action === "read") return service.markRead(ctx.principal, id, input.seq)
return { members: await service.members(ctx.principal, id) }
}
@@ -121,9 +118,6 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll
server.get("/collaboration/rooms/:id/messages", async (req) => request(req, "messages"))
server.post("/collaboration/rooms/:id/messages", async (req) => request(req, "send"))
server.post("/collaboration/rooms/:id/read", async (req) => request(req, "read"))
server.get("/collaboration/rooms/:id/image", async (req) => request(req, "image"))
server.get("/collaboration/rooms/:id/reactions", async (req) => request(req, "reactions"))
server.put("/collaboration/rooms/:id/reactions", async (req) => request(req, "react"))
server.get("/collaboration/rooms/:id/members", async (req) => request(req, "members"))
}
@@ -143,8 +137,5 @@ export async function collaborationGuestRoutes(server: FastifyInstance, { servic
server.get("/guest/api/rooms/:id/messages", async (req: any) => service.messages(await principal(req), req.params.id, req.query))
server.get("/guest/api/rooms/:id/members", async (req: any) => ({ members: await service.members(await principal(req), req.params.id) }))
server.post("/guest/api/rooms/:id/messages", async (req: any) => service.send(await principal(req), req.params.id, body(req)))
server.get("/guest/api/rooms/:id/image", async (req: any) => service.image(await principal(req), req.params.id, req.query))
server.get("/guest/api/rooms/:id/reactions", async (req: any) => service.reactions(await principal(req), req.params.id, req.query))
server.put("/guest/api/rooms/:id/reactions", async (req: any) => service.react(await principal(req), req.params.id, body(req)))
server.post("/guest/api/rooms/:id/read", async (req: any) => service.markRead(await principal(req), req.params.id, body(req).seq))
}

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify"
import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm"
import {
authProfiles,
authTenantUsers,
@@ -9,13 +9,11 @@ import {
communicationRoomMembers,
communicationRoomReads,
communicationRooms,
communicationHiddenRooms,
notificationsItems,
projects,
} from "../../db/schema"
import { NotificationService, UserDirectory } from "../modules/notification.service"
import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access"
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../modules/chat-content"
type ChatUser = {
userId: string
@@ -80,7 +78,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, tenantId)
))
.where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at)))
.where(eq(authTenantUsers.tenant_id, tenantId))
const addMembers = async (roomId: string, userIds: string[]) => {
const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean)))
@@ -122,8 +120,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
.where(eq(communicationRooms.id, room.id)).returning()
}
const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers)
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
.where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at)))
.where(eq(authTenantUsers.tenant_id, tenantId))
if (users.length) await tx.insert(communicationRoomMembers)
.values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId })))
.onConflictDoNothing()
@@ -158,24 +155,11 @@ export default async function communicationRoutes(server: FastifyInstance) {
entityUuid: room.entityUuid,
exists: true,
})
// Rooms without messages fall back to their creation date so new rooms still sort by recency.
const withActivity = async <T extends { room: typeof communicationRooms.$inferSelect }>(rows: T[]) => {
const ids = rows.map(({ room }) => room.id)
const latest = ids.length ? await server.db.select({
roomId: communicationMessages.roomId,
at: sql<Date>`max(${communicationMessages.createdAt})`.mapWith(communicationMessages.createdAt),
}).from(communicationMessages).where(inArray(communicationMessages.roomId, ids)).groupBy(communicationMessages.roomId) : []
const byRoom = new Map(latest.map((row) => [row.roomId, row.at]))
return (room: typeof communicationRooms.$inferSelect) => (byRoom.get(room.id) || room.createdAt).toISOString()
}
const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
const conditions = [eq(communicationMessages.roomId, roomId)]
if (afterId > 0) conditions.push(gt(communicationMessages.id, afterId))
const rows = await server.db.select({ id: communicationMessages.id, body: communicationMessages.body,
authorUserId: communicationMessages.authorUserId, createdAt: communicationMessages.createdAt,
image: sql`${communicationMessages.image} - 'dataUrl'`,
}).from(communicationMessages)
const rows = await server.db.select().from(communicationMessages)
.where(and(...conditions))
.orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id))
.limit(Math.min(Math.max(limit, 1), 100))
@@ -196,7 +180,6 @@ export default async function communicationRoutes(server: FastifyInstance) {
return orderedRows.map((message) => ({
id: message.id,
body: message.body,
image: imageMetadata(message.image),
sender: message.authorUserId,
senderDisplayName: displayName(authorById.get(message.authorUserId) || {}),
timestamp: message.createdAt.getTime(),
@@ -217,34 +200,32 @@ export default async function communicationRoutes(server: FastifyInstance) {
.from(communicationRoomMembers).where(eq(communicationRoomMembers.roomId, room.id))
const memberIds = new Set(memberRows.map((member) => member.userId))
const normalizedText = message.body.toLowerCase()
const recipients = users.filter((user) => user.userId !== senderId && memberIds.has(user.userId))
const isMentioned = (user: ChatUser) => {
const recipients = users.filter((user) => {
if (user.userId === senderId || !memberIds.has(user.userId)) return false
if (room.type === "direct") return true
const aliases = [displayName(user), user.fullName, user.firstName, user.email]
.filter(Boolean).map((value) => String(value).toLowerCase())
return aliases.some((alias) => normalizedText.includes(`@${alias}`))
}
})
if (!recipients.length) return
const senderName = sender ? displayName(sender) : "FEDEO"
for (const eventType of ["communication.message.direct", "communication.message.mentioned", "communication.message.room"]) {
const targets = recipients.filter((user) => (room.type === "direct" ? "communication.message.direct" : isMentioned(user) ? "communication.message.mentioned" : "communication.message.room") === eventType)
if (!targets.length) continue
await notifications.trigger({
tenantId,
userIds: targets.map((recipient) => recipient.userId),
eventType,
title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : eventType === "communication.message.mentioned" ? `${senderName} hat dich erwähnt` : `Neue Nachricht in ${room.name}`,
message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body,
payload: {
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
roomKey: room.key,
roomName: room.name,
roomType: room.type,
messageId: message.id,
mentioned: eventType === "communication.message.mentioned",
direct: room.type === "direct",
},
})
}
await notifications.trigger({
tenantId,
userIds: recipients.map((recipient) => recipient.userId),
eventType: "communication.message.new",
title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : `${senderName} hat dich erwähnt`,
message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body,
payload: {
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
roomKey: room.key,
roomName: room.name,
roomType: room.type,
messageId: message.id,
mentioned: room.type !== "direct",
direct: room.type === "direct",
},
channels: ["inapp", "push"],
})
} catch (err) {
server.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden")
}
@@ -263,28 +244,6 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" }))
server.get("/communication/chat/room-preferences", async (req: any) => {
const tenantId = await requireTenant(req)
const hidden = await server.db.select({ key: communicationHiddenRooms.roomKey }).from(communicationHiddenRooms)
.where(and(eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id)))
return { hiddenRoomKeys: hidden.map(room => room.key) }
})
server.put("/communication/chat/room-preferences", async (req: any, reply) => {
const tenantId = await requireTenant(req)
const { roomKey, hidden } = req.body || {}
if (typeof hidden !== "boolean" || typeof roomKey !== "string" || roomKey.length > 200
|| !/^(?:[a-z0-9._=-]+|collaboration:[a-f0-9-]{36})$/.test(roomKey)) {
return reply.code(400).send({ error: "Ungültige Raumeinstellung" })
}
const value = { tenantId, userId: req.user.user_id, roomKey }
if (hidden) await server.db.insert(communicationHiddenRooms).values(value).onConflictDoNothing()
else await server.db.delete(communicationHiddenRooms).where(and(
eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id),
eq(communicationHiddenRooms.roomKey, roomKey)))
return { roomKey, hidden }
})
server.get("/communication/chat/users", async (req: any) => {
const tenantId = await requireTenant(req)
return { users: (await tenantUsers(tenantId)).map((user) => ({
@@ -305,8 +264,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
eq(communicationRooms.archived, false),
eq(communicationRoomMembers.userId, req.user.user_id)
)).orderBy(asc(communicationRooms.name))
const activity = await withActivity(rows)
return { rooms: rows.map(({ room }) => ({ ...roomToApi(room), lastActivityAt: activity(room) })) }
return { rooms: rows.map(({ room }) => roomToApi(room)) }
})
server.post("/communication/chat/rooms", async (req: any, reply) => {
@@ -326,14 +284,6 @@ export default async function communicationRoutes(server: FastifyInstance) {
return room
})
if (!created) return reply.code(409).send({ error: "Dieser Raumschlüssel ist bereits vergeben" })
const recipients = userIds.filter((id) => id !== req.user.user_id)
if (recipients.length) {
try {
await notifications.trigger({ tenantId, userIds: recipients, eventType: "communication.room.invited",
title: "Neuer Chatraum", message: `Du wurdest dem Raum „${created.name}“ hinzugefügt.`,
payload: { link: `/communication/chat?room=${encodeURIComponent(created.key)}` } })
} catch (err) { server.log.error({ err }, "Raum-Benachrichtigung fehlgeschlagen") }
}
return { ...roomToApi(created), alreadyExisted: false }
})
@@ -346,10 +296,9 @@ export default async function communicationRoutes(server: FastifyInstance) {
projectChatAccess(server.db, req),
])
const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room]))
const activity = await withActivity(roomRows)
return { rooms: projectRows.map((project) => {
const room = roomsByProject.get(project.id)
return room ? { ...roomToApi(room), lastActivityAt: activity(room), projectId: project.id, projectNumber: project.projectNumber } : {
return room ? { ...roomToApi(room), projectId: project.id, projectNumber: project.projectNumber } : {
key: `project_${project.id}`,
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`,
@@ -385,7 +334,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
const profileIds = (project.profiles || []) as string[]
const memberProfiles = profileIds.length
? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles)
.where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds), eq(authProfiles.active, true)))
.where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds)))
: []
const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[]
await addMembers(room.id, [req.user.user_id, ...projectMemberIds])
@@ -399,13 +348,11 @@ export default async function communicationRoutes(server: FastifyInstance) {
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id)))
const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room]))
const activity = await withActivity(existingRows)
return { rooms: users.map((user) => {
const key = directRoomKey(req.user.user_id, user.userId)
const room = roomsByKey.get(key)
return room ? {
...roomToApi(room),
lastActivityAt: activity(room),
name: displayName(user),
topic: `Direktnachricht mit ${displayName(user)}`,
entityUuid: user.userId,
@@ -486,33 +433,17 @@ export default async function communicationRoutes(server: FastifyInstance) {
return { ...roomToApi(room), messages, nextId: messages[messages.length - 1]?.id || afterId }
})
server.post("/communication/chat/rooms/:roomKey/messages", async (req: any) => {
server.post("/communication/chat/rooms/:roomKey/messages", async (req: any, reply) => {
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
const { body, image } = await chatContent(req.body)
const body = String(req.body?.text || "").trim()
if (!body) return reply.code(400).send({ error: "Nachricht darf nicht leer sein" })
if (body.length > 10_000) return reply.code(400).send({ error: "Nachricht ist zu lang" })
const [created] = await server.db.insert(communicationMessages).values({
tenantId, roomId: room.id, authorUserId: req.user.user_id, body, image,
tenantId, roomId: room.id, authorUserId: req.user.user_id, body,
}).returning()
await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body: body || "📷 Bild" })
return { id: created.id, body: created.body, image: imageMetadata(created.image), sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true }
})
server.get("/communication/chat/rooms/:roomKey/image", async (req: any, reply) => {
reply.header("Cache-Control", "no-store")
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
return chatImage(server.db, "communication", room.id, req.query.messageId)
})
server.get("/communication/chat/rooms/:roomKey/reactions", async (req: any, reply) => {
reply.header("Cache-Control", "no-store")
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
return chatReactions(server.db, "communication", room.id, req.user.user_id, req.query.ids)
})
server.put("/communication/chat/rooms/:roomKey/reactions", async (req: any) => {
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
return setChatReaction(server.db, "communication", room.id, req.user.user_id, req.body)
await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body })
return { id: created.id, body: created.body, sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true }
})
server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => {

View File

@@ -1,6 +1,3 @@
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { TimeService, digest, rows } from "../../modules/time-v2/service";
import { FastifyInstance } from "fastify";
import { and, desc, eq } from "drizzle-orm";
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
@@ -11,8 +8,7 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
async (req, reply) => {
try {
// 1. Timestamp aus dem Body holen (optional)
const { rfid_id, timestamp, request_id } = req.body as {
request_id?: string,
const { rfid_id, timestamp } = req.body as {
rfid_id: string,
timestamp?: number // Kann undefined sein (Live) oder Zahl (Offline)
};
@@ -44,8 +40,7 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
.where(
and(
eq(authProfiles.tenant_id, device.tenant),
eq(authProfiles.token_id, rfid_id),
eq(authProfiles.active, true)
eq(authProfiles.token_id, rfid_id)
)
)
.limit(1)
@@ -56,28 +51,11 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
return reply.code(400).send(`Profile for Token ${rfid_id} not found`);
}
const timeService = new TimeService(server.db);
const actor = { tenant: device.tenant, user: profile.user_id, trusted: true };
if (profile.user_id && await timeService.active(actor, profile.user_id)) {
return await server.db.transaction(async tx => {
await timeService.lock(tx, actor, profile.user_id);
const scoped = new TimeService(tx);
const at = timestamp && timestamp > 0 ? new Date(timestamp * 1000).toISOString() : new Date().toISOString();
const key = `rfid:${digest({ terminal_id, rfid_id, event: request_id || timestamp || randomUUID() })}`;
const [cached] = await rows(tx, sql`SELECT response FROM staff_time_v2_requests WHERE tenant_id=${device.tenant} AND user_id=${profile.user_id}::uuid AND request_id=${key}`);
if (cached) return cached.response;
const current = await scoped.current(actor, profile.user_id);
return scoped.mutate(actor, profile.user_id, key, current
? { action: 'stop', id: current.id, version: current.version, at }
: { action: 'start', at }, 'terminal');
});
}
// 4. Letztes Event suchen (für Status-Toggle Work Start/End)
const lastEvent = await server.db
.select()
.from(stafftimeevents)
.where(and(eq(stafftimeevents.user_id, profile.user_id), eq(stafftimeevents.tenant_id, device.tenant)))
.where(eq(stafftimeevents.user_id, profile.user_id))
.orderBy(desc(stafftimeevents.eventtime))
.limit(1)
.then(rows => rows[0]);

View File

@@ -41,9 +41,6 @@ export default async function documentInboxRoutes(server: FastifyInstance, optio
server.post<{ Params: { id: string } }>("/document-inbox/:id/suggestion", {
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
}, async (req, reply) => reply.code(202).send(await service.queueSuggestion(req.user.tenant_id!, req.params.id)))
server.post<{ Params: { id: string } }>("/document-inbox/:id/archive", {
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
}, async req => service.archiveDocument(req.user.tenant_id!, req.user.user_id, req.params.id))
server.post<{ Params: { id: string }; Body: { folder: string | null; type: string; assignments?: FileAssignments } }>("/document-inbox/:id/file", {
schema: {
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },

View File

@@ -20,9 +20,8 @@ import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys, files} from "../../db/schema";
import {and, eq, isNull, not} from "drizzle-orm";
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
import { updateIncomingInvoiceCostCentres } from "../modules/incoming-invoice-cost-centres.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
@@ -302,13 +301,6 @@ export default async function functionRoutes(server: FastifyInstance) {
return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
})
server.post('/functions/serial/cancel/:execution_id', {
schema: { params: { type: 'object', required: ['execution_id'], properties: { execution_id: { type: 'string', format: 'uuid' } } } },
}, async (req) => {
const { execution_id } = req.params as { execution_id: string }
return cancelManualGeneration(server, execution_id, req.user.tenant_id)
})
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
const {execution_id} = req.params as { execution_id: string }
//@ts-ignore
@@ -335,26 +327,6 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
server.put('/functions/incoming-invoices/:id/cost-centres', async (req, reply) => {
try {
const { id } = req.params as { id: string }
const invoiceId = Number(id)
if (!Number.isFinite(invoiceId)) return reply.code(400).send({ error: "Ungültige Eingangsbeleg-ID" })
const invoice = await updateIncomingInvoiceCostCentres(
server,
req.user.tenant_id,
req.user.user_id,
invoiceId,
req.body as any,
)
return { invoice }
} catch (error) {
const statusCode = (error as any)?.statusCode || 500
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
}
})
server.post('/functions/services/bankstatementsync', async (req, reply) => {
const result = await server.services.bankStatements.run(req.user.tenant_id);
if (result.errors.length > 0) {
@@ -369,11 +341,8 @@ export default async function functionRoutes(server: FastifyInstance) {
})
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
return reply.code(202).send(server.services.prepareIncomingInvoices.start(req.user.tenant_id))
})
server.get('/functions/services/prepareincominginvoices', async (req) => {
return server.services.prepareIncomingInvoices.status(req.user.tenant_id)
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
})
server.get('/functions/liquidity-forecast', async (req, reply) => {

View File

@@ -7,7 +7,7 @@ import {
tenants
} from "../../../db/schema"
import {and, eq, inArray, isNull} from "drizzle-orm"
import {and, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
@@ -52,7 +52,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const users = await server.db
.select()
.from(authUsers)
.where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at)))
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profileRows = await server.db
@@ -61,9 +61,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
.where(
and(
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds),
eq(authProfiles.active, true),
isNull(authProfiles.archived_at)
inArray(authProfiles.user_id, userIds)
))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -100,7 +98,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const profileRows = await server.db
.select()
.from(authProfiles)
.where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.archived_at)))
.where(eq(authProfiles.tenant_id, tenantId))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
return await enrichProfilesWithTeams(server, profilesWithBranches)

View File

@@ -1,4 +1,3 @@
import { installTimeCompatibility } from "../../modules/time-v2/compatibility";
import { FastifyInstance } from "fastify"
import {
stafftimeentries,
@@ -19,7 +18,6 @@ import {z} from "zod";
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
installTimeCompatibility(server, true);
server.post("/staff/time/event", async (req, reply) => {

View File

@@ -6,16 +6,6 @@ import { assertToolPermission, createMcpContext } from "../mcp/authz"
import { mcpToolMap, mcpTools } from "../mcp/registry"
import { asToolError, asToolResult } from "../mcp/result"
import { JsonRpcRequest } from "../mcp/types"
import { assertInternalDocumentAccess, loadDocumentFile, MCP_BODY_LIMIT, MCP_FILE_PERMISSION } from "../mcp/documentFiles"
import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { sanitizeFilename } from "../utils/filename"
import multipart from "@fastify/multipart"
import { MCP_FILE_LIMIT } from "../mcp/documentFiles"
import { parseUploadQuery, storeMcpFileUpload } from "../mcp/fileUploads"
const RAW_UPLOAD_TYPES = ["application/pdf", "application/octet-stream", "application/xml", "text/xml", "image/jpeg", "image/png", "image/tiff"]
const SUPPORTED_PROTOCOL_VERSIONS = [
"2025-11-25",
@@ -67,13 +57,7 @@ const requireMcpTokenManagementPermission = (req: any) => {
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
}
type McpRouteOptions = {
/** Replaceable in tests to avoid object storage. */
storeUpload?: typeof storeMcpFileUpload
}
export default async function mcpRoutes(server: FastifyInstance, options: McpRouteOptions = {}) {
const storeUpload = options.storeUpload || storeMcpFileUpload
export default async function mcpRoutes(server: FastifyInstance) {
server.get("/mcp/tokens", async (req, reply) => {
try {
requireMcpTokenManagementPermission(req)
@@ -211,59 +195,7 @@ export default async function mcpRoutes(server: FastifyInstance, options: McpRou
}
})
server.get<{ Params: { id: string } }>("/mcp/files/:id/content", {
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
}, async (req, reply) => {
try {
const context = await createMcpContext(server, req)
assertToolPermission(context, { name: "documents.files.download", requiredPermissions: [MCP_FILE_PERMISSION] })
await assertInternalDocumentAccess(context)
const file = await loadDocumentFile(context, req.params.id)
if (!file.path) return reply.code(404).send({ error: "Kein Dateiinhalt verfügbar." })
const { Body } = await s3.send(new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path }))
if (!Body) return reply.code(404).send({ error: "Kein Dateiinhalt verfügbar." })
reply.header("Content-Type", file.mimeType || "application/octet-stream")
reply.header("Content-Disposition", `attachment; filename="${sanitizeFilename(file.name)}"`)
reply.header("Cache-Control", "no-store")
reply.header("X-Content-Type-Options", "nosniff")
return reply.send(Body)
} catch (error) {
return reply.code((error as any)?.statusCode || 500).send({ error: error instanceof Error ? error.message : "Download fehlgeschlagen." })
}
})
// Binary uploads without Base64; parsers stay scoped so JSON-RPC parsing is unaffected.
await server.register(async (uploads) => {
await uploads.register(multipart, { limits: { fileSize: MCP_FILE_LIMIT, files: 1 } })
uploads.addContentTypeParser(RAW_UPLOAD_TYPES, { parseAs: "buffer", bodyLimit: MCP_FILE_LIMIT }, (_req, body, done) => done(null, body))
uploads.post("/mcp/files", async (req, reply) => {
try {
const context = await createMcpContext(server, req)
const query = parseUploadQuery(req.query as Record<string, unknown>)
let body: Buffer
let filename = query.filename
let mimeType = req.headers["content-type"]?.split(";")[0].trim() || null
if (req.isMultipart()) {
const part = await req.file()
if (!part || part.fieldname !== "file") return reply.code(400).send({ error: "Bitte die Datei im Feld file übertragen." })
body = await part.toBuffer()
filename ||= part.filename
mimeType = part.mimetype
} else if (Buffer.isBuffer(req.body)) {
body = req.body
} else {
return reply.code(415).send({ error: `Bitte multipart/form-data oder einen der Content-Types ${RAW_UPLOAD_TYPES.join(", ")} verwenden.` })
}
if (!filename) return reply.code(400).send({ error: "filename ist erforderlich." })
return await storeUpload(context, { ...query, filename, body, mimeType })
} catch (error) {
return reply.code((error as any)?.statusCode || 400).send({ error: error instanceof Error ? error.message : "Upload fehlgeschlagen." })
}
})
})
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
server.post("/mcp", async (req, reply) => {
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
const requests = Array.isArray(body) ? body : [body]
const responses = []
@@ -294,7 +226,7 @@ export default async function mcpRoutes(server: FastifyInstance, options: McpRou
name: "fedeo-mcp",
version: "1.0.0",
},
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs-, Organisations- und Dokumentenwerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten. Dokumentenuploads erscheinen in der Dokumentenablage; Download-URLs benötigen denselben Bearer-Token wie MCP.",
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.",
}))
continue
}
@@ -312,7 +244,7 @@ export default async function mcpRoutes(server: FastifyInstance, options: McpRou
description: tool.description,
inputSchema: tool.inputSchema,
annotations: {
readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
},
})),
}))

View File

@@ -23,12 +23,6 @@ const requireTenant = (tenantId: number | null) => {
export default async function notificationsRoutes(server: FastifyInstance) {
const svc = new NotificationService(server, getUserDirectory)
server.get("/notifications/preferences", async (req) =>
svc.preferences(requireTenant(req.user.tenant_id), req.user.user_id))
server.put("/notifications/preferences", async (req) =>
svc.savePreferences(requireTenant(req.user.tenant_id), req.user.user_id, req.body))
server.get("/notifications", async (req) => {
const limit = Number((req.query as { limit?: string })?.limit || 50)
return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit)

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify";
import { eq, and, isNull } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import {
authProfiles,
@@ -57,7 +57,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name",
"branch", "archived_at", "calendar_subscription_token",
"branch", "calendar_subscription_token",
"calendar_subscription_path", "calendar_subscription_url"
]
forbidden.forEach(f => delete cleaned[f])
@@ -135,8 +135,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
.where(
and(
eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId),
isNull(authProfiles.archived_at)
eq(authProfiles.tenant_id, tenantId)
)
)
.returning()

View File

@@ -1,5 +1,4 @@
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
import { projectChatSettingError } from "../../modules/communication-access"
import { FastifyInstance } from "fastify"
import {
eq,
@@ -9,14 +8,12 @@ import {
and,
count,
inArray,
isNull,
or,
sql,
} from "drizzle-orm"
import {
authProfiles,
authUsers,
createddocuments,
customers,
entitybankaccounts,
@@ -140,10 +137,7 @@ function buildFieldUpdateHistoryText(resource: string, label: string, oldValue:
return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
}
function applyResourceWhereFilters(resource: string, table: any, whereCond: any, list = false) {
if (list && table === authProfiles) {
return and(whereCond, eq(authProfiles.active, true), isNull(authProfiles.archived_at))
}
function applyResourceWhereFilters(resource: string, table: any, whereCond: any) {
if (resource === "members") {
return and(whereCond, eq(table.type, "Mitglied"))
}
@@ -575,7 +569,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table)
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
whereCond = applyResourceWhereFilters(resource, table, whereCond, true)
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
let q = server.db.select().from(table).$dynamic()
@@ -693,7 +687,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table);
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
whereCond = applyResourceWhereFilters(resource, table, whereCond, true)
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
@@ -794,7 +788,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
});
}
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond, true)
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) {
@@ -883,7 +877,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
const table = resourceConfig[resource].table
let whereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId))
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
@@ -948,19 +942,6 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = config.table;
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
if (table === authProfiles) {
delete createData.archived_at;
createData.tenant_id = req.user.tenant_id;
if (createData.user_id) {
const [owner] = await server.db.select({ id: authUsers.id }).from(authUsers)
.where(and(eq(authUsers.id, createData.user_id), isNull(authUsers.archived_at))).limit(1);
if (!owner) return reply.code(409).send({ error: "Aktiver Benutzer nicht gefunden." });
}
}
if (resource === "projects") {
const error = projectChatSettingError(req, createData)
if (error) return reply.code(error.statusCode).send({ error: error.error })
}
if (resource === "members") {
createData = normalizeMemberPayload(createData)
@@ -1107,25 +1088,14 @@ export default async function resourceRoutes(server: FastifyInstance) {
const [oldRecord] = await server.db
.select()
.from(table)
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)), portalCustomerId))
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
.limit(1)
if (!oldRecord) {
return reply.code(404).send({ error: "Resource not found" })
}
if (resource === "projects") {
const error = projectChatSettingError(req, body, oldRecord.chatEnabled)
if (error) return reply.code(error.statusCode).send({ error: error.error })
}
if (table === authProfiles && oldRecord.archived_at) {
return reply.code(409).send({ error: "Archivierte Profile können nicht mehr bearbeitet werden." })
}
let data: Record<string, any> = { ...body }
if (table === authProfiles) {
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete data[key]
}
delete data.updatedAt
delete data.updated_at
delete data.updatedBy
@@ -1214,8 +1184,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
})
let updateWhereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId))
if (table === authProfiles) updateWhereCond = and(updateWhereCond, isNull(authProfiles.archived_at))
let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
@@ -1224,7 +1193,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
.update(table)
.set({ defaultMandate: false })
.where(and(
eq(getTenantColumn(resource, table), tenantId),
eq(table.tenant, tenantId),
eq(table.customer, updated.customer),
sql`${table.id} <> ${updated.id}`
))

View File

@@ -1,62 +0,0 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { sql } from 'drizzle-orm';
import { Actor, can, rows, TimeService } from '../../modules/time-v2/service';
import { TimeMigration } from '../../modules/time-v2/migration';
import { fail, range } from '../../modules/time-v2/domain';
export function timeActor(req: FastifyRequest): Actor {
return { tenant: req.user.tenant_id!, user: req.user.user_id, admin: req.user.is_admin, permissions: req.permissions };
}
export function uuid(value: unknown): string {
if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
fail('Ungültige ID.');
return value as string;
}
export default async function timeV2Routes(server: FastifyInstance) {
const service = new TimeService(server.db), migration = new TimeMigration(service);
server.setErrorHandler((error: any, req, reply) => {
if (error.statusCode && error.statusCode < 500)
return reply.code(error.statusCode).send({ error: error.message });
req.log.error(error);
return reply.code(500).send({ error: 'Zeiterfassung konnte nicht verarbeitet werden.' });
});
server.get('/v2/time/context', async (req) => {
const a = timeActor(req);
const all = can(a, 'staff.time.read_all') || can(a, 'staff.time.write_all') || can(a, 'staff.time.approve') || can(a, 'staff.time.activate');
const profiles = await rows(server.db, sql `SELECT p.user_id,p.full_name,a.activated_at FROM auth_profiles p
LEFT JOIN staff_time_v2_accounts a ON a.tenant_id=p.tenant_id AND a.user_id=p.user_id
WHERE p.tenant_id=${a.tenant} AND p.user_id IS NOT NULL ${all ? sql `` : sql `AND p.user_id=${a.user}::uuid`} ORDER BY p.full_name`);
const projects = await rows(server.db, sql `SELECT id,name FROM projects WHERE tenant=${a.tenant} AND NOT archived ORDER BY name`);
return { profiles, projects, permissions: { readAll: all, writeAll: can(a, 'staff.time.write_all'), approve: can(a, 'staff.time.approve'), activate: can(a, 'staff.time.activate') } };
});
server.get('/v2/time/spans', async (req) => {
const q = req.query as any, a = timeActor(req), user = uuid(q.user_id || a.user);
if (!q.from || !q.to)
fail('Zeitraum erforderlich.');
range(q.from, q.to);
const items = await service.list(a, user, q.from, q.to);
const active = await service.current(a, user);
return { items, active, account: await service.active(a, user) };
});
server.get('/v2/time/evaluation', async (req) => {
const q = req.query as any, a = timeActor(req);
range(q.from, q.to);
return service.evaluation(a, uuid(q.user_id || a.user), q.from, q.to);
});
server.get('/v2/time/spans/:id/history', req => service.history(timeActor(req), uuid((req.params as any).id)));
server.post('/v2/time/commands', async (req) => {
const b = req.body as any, a = timeActor(req);
if (!b || typeof b.command !== 'object' || !b.command)
fail('Aktion erforderlich.');
if (b.command.id)
uuid(b.command.id);
return service.mutate(a, uuid(b.user_id || a.user), b.request_id, b.command);
});
server.get('/v2/time/migration', req => {
const a = timeActor(req);
return migration.preview(a, uuid((req.query as any).user_id || a.user));
});
server.post('/v2/time/activate', req => {
const b = req.body as any, a = timeActor(req);
return migration.activate(a, uuid(b?.user_id || a.user), b?.fingerprint);
});
}

View File

@@ -1,4 +1,3 @@
import { installTimeCompatibility } from "../../modules/time-v2/compatibility";
import { FastifyInstance } from "fastify"
import {
stafftimeentries,
@@ -19,7 +18,6 @@ import {z} from "zod";
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
export default async function staffTimeRoutes(server: FastifyInstance) {
installTimeCompatibility(server);
server.post("/staff/time/event", async (req, reply) => {

View File

@@ -1,31 +1,9 @@
import { sql } from "drizzle-orm";
import { authorize, rows, TimeService } from "../../modules/time-v2/service";
import { timeActor, uuid } from "./time-v2";
import { FastifyInstance } from 'fastify'
import { StaffTimeEntryConnect } from '../../types/staff'
import { asc, eq } from "drizzle-orm";
import { stafftimenetryconnects } from "../../../db/schema";
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
server.addHook('preHandler', async (req, reply) => {
try {
const a = timeActor(req), params = req.params as any;
const id = uuid(params.id || params.connectId);
const [owner] = await rows(server.db, params.id
? sql`SELECT e.user_id,e.id FROM staff_time_entries e WHERE e.id=${id}::uuid AND e.tenant_id=${a.tenant}`
: sql`SELECT e.user_id,e.id FROM staff_time_entry_connects c JOIN staff_time_entries e ON e.id=c.time_entry_id WHERE c.id=${id}::uuid AND e.tenant_id=${a.tenant}`);
if (!owner) return reply.code(404).send({error:'Zeiteintrag nicht gefunden.'});
authorize(a,owner.user_id,req.method === 'GET' ? 'read' : 'write');
const service = new TimeService(server.db);
if (await service.active(a,owner.user_id)) {
if (req.method !== 'GET') return reply.code(409).send({error:'Projektzeiten unter 2.0 → Zeiterfassung bearbeiten.'});
const [origin] = await rows(server.db,sql`SELECT span_id FROM staff_time_span_origins WHERE tenant_id=${a.tenant} AND source_kind='entry' AND source_id=${owner.id}::uuid`);
const span = origin ? await service.get(a,origin.span_id) : null;
return reply.send((span?.projects || []).map(p => ({...p,stopped_at:p.ended_at})));
}
} catch (e:any) { return reply.code(e.statusCode || 400).send({error:e.message}); }
});
// ▶ Connect anlegen
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
@@ -70,8 +48,7 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const patchData = Object.fromEntries(Object.entries(req.body).filter(([key]) =>
['started_at', 'stopped_at', 'project_id', 'notes'].includes(key))) as any
const patchData = { ...req.body } as any
if (patchData.started_at) patchData.started_at = new Date(patchData.started_at)
if (patchData.stopped_at) patchData.stopped_at = new Date(patchData.stopped_at)
if (patchData.project_id !== undefined) {

View File

@@ -11,7 +11,7 @@ import {
m2mApiKeys
} from "../../db/schema"
import {and, desc, eq, inArray, isNull} from "drizzle-orm"
import {and, desc, eq, inArray} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams"
import { rotateRefreshToken } from "../utils/authTokens"
@@ -152,7 +152,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
const users = await server.db
.select()
.from(authUsers)
.where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at)))
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profileRows = await server.db
@@ -161,9 +161,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
.where(
and(
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds),
eq(authProfiles.active, true),
isNull(authProfiles.archived_at)
inArray(authProfiles.user_id, userIds)
))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -196,14 +194,10 @@ export default async function tenantRoutes(server: FastifyInstance) {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true"
const profileRows = await server.db
.select()
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
...(includeArchived ? [] : [eq(authProfiles.active, true), isNull(authProfiles.archived_at)])
))
.where(eq(authProfiles.tenant_id, tenantId))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -277,16 +271,6 @@ export default async function tenantRoutes(server: FastifyInstance) {
if (!data) return reply.code(400).send({ error: "data required" })
if (Object.prototype.hasOwnProperty.call(data, "portalConfig")) {
const config = data.portalConfig
if (!config || typeof config !== "object" || Array.isArray(config)
|| typeof config.primaryColor !== "string" || !/^#[0-9a-f]{6}$/i.test(config.primaryColor.trim())) {
return reply.code(400).send({ error: "Die Portal-Akzentfarbe muss ein Hex-Farbwert wie #69c350 sein." })
}
data.portalConfig = { ...config, primaryColor: config.primaryColor.trim().toLowerCase() }
delete data.portalConfig.primayColor
}
const updated = await server.db
.update(tenants)
.set(data)

View File

@@ -70,7 +70,7 @@ export async function rotateRefreshToken(
const [user] = await server.db
.select({ id: authUsers.id, email: authUsers.email })
.from(authUsers)
.where(and(eq(authUsers.id, session.userId), isNull(authUsers.archived_at)))
.where(eq(authUsers.id, session.userId))
.limit(1)
if (!user) return null

View File

@@ -1,5 +1,5 @@
import crypto from "crypto"
import { and, asc, eq, isNull, sql } from "drizzle-orm"
import { and, asc, eq, sql } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { authProfiles, events } from "../../db/schema"
@@ -105,8 +105,7 @@ export async function generateProfileCalendarSubscriptionToken(server: FastifyIn
.where(
and(
eq(authProfiles.id, profileId),
eq(authProfiles.tenant_id, tenantId),
isNull(authProfiles.archived_at)
eq(authProfiles.tenant_id, tenantId)
)
)
.returning()

View File

@@ -2,21 +2,17 @@ import { GetObjectCommand } from "@aws-sdk/client-s3"
import { s3 } from "./s3"
import { secrets } from "./secrets"
export const streamToBuffer = async (stream: any, maxBytes = Infinity): Promise<Buffer> => {
export const streamToBuffer = async (stream: any): Promise<Buffer> => {
const chunks: Buffer[] = []
let size = 0
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
size += buffer.length
if (size > maxBytes) throw new Error("Die Datei ist für Base64 zu groß. Bitte den Download mit format: url verwenden.")
chunks.push(buffer)
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
}
export const loadFileBuffer = async (path: string, maxBytes = Infinity): Promise<Buffer> => {
export const loadFileBuffer = async (path: string): Promise<Buffer> => {
const response = await s3.send(new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: path,
@@ -26,5 +22,5 @@ export const loadFileBuffer = async (path: string, maxBytes = Infinity): Promise
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
}
return streamToBuffer(response.Body, maxBytes)
return streamToBuffer(response.Body)
}

View File

@@ -50,7 +50,7 @@ export async function insertHistoryItem(
created_by: string | null
entity: string
entityId: string | number
action: "created" | "updated" | "unchanged" | "deleted" | "archived" | "restored"
action: "created" | "updated" | "unchanged" | "deleted" | "archived"
oldVal?: any
newVal?: any
text?: string
@@ -62,7 +62,6 @@ export async function insertHistoryItem(
updated: `Eintrag in ${entityLabel} geändert`,
unchanged: `Eintrag in ${entityLabel} unverändert`,
archived: `Eintrag in ${entityLabel} archiviert`,
restored: `Eintrag in ${entityLabel} wiederhergestellt`,
deleted: `Eintrag in ${entityLabel} gelöscht`
}

View File

@@ -1,33 +0,0 @@
import type { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import { authProfiles, authTenantUsers } from "../../db/schema"
export async function resolveLoginTenant(server: FastifyInstance, userId: string, domainTenantId: number | null) {
const memberships = await server.db
.selectDistinct({
tenantId: authTenantUsers.tenant_id,
portalCustomerId: authProfiles.customer_for_portal,
})
.from(authTenantUsers)
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, authTenantUsers.tenant_id),
eq(authProfiles.active, true)
))
.where(eq(authTenantUsers.user_id, userId))
if (domainTenantId) {
if (!memberships.some((membership) => membership.tenantId === domainTenantId)) {
throw Object.assign(new Error("Not a member of this tenant"), { statusCode: 403 })
}
return domainTenantId
}
// Portal invitations assign one customer account. Load its profile immediately,
// even when the customer signs in through the shared FEDEO address.
if (memberships.length === 1 && memberships[0].portalCustomerId) {
return memberships[0].tenantId
}
return null
}

View File

@@ -488,8 +488,6 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
const getTitleKey = (row) => `${row.pos} - ${row.text}`
const getTransferSumText = (row) => {
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) return null
const transferSum = invoiceData.total.titleSumsTransfer[getTitleKey(row)]
return transferSum ? `Übertrag: ${transferSum}` : null

View File

@@ -1,29 +0,0 @@
type PortalUrlSettings = {
portalDomain?: string | null
publicUrl?: string
apiBaseUrl?: string
}
/** Use deployment configuration, never request headers, for links sent by email. */
export function getPortalLoginUrl({ portalDomain, publicUrl, apiBaseUrl }: PortalUrlSettings) {
const domain = portalDomain?.trim()
const candidates = [
{ value: domain ? (/^https?:\/\//i.test(domain) ? domain : `https://${domain}`) : undefined },
{ value: publicUrl?.trim() },
{ value: apiBaseUrl?.trim(), originOnly: true },
]
for (const { value, originOnly } of candidates) {
if (!value) continue
try {
const url = new URL(value)
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) continue
url.pathname = `${originOnly ? "" : url.pathname.replace(/\/+$/, "")}/login`
url.search = ""
url.hash = ""
return url.toString()
} catch { /* Try the next configured address. */ }
}
throw new Error("Für die Portal-Einladung fehlt eine gültige öffentliche FEDEO-Adresse.")
}

View File

@@ -1,5 +1,5 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { and, eq, isNull } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { authUsers } from "../../db/schema/auth_users";
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
@@ -9,7 +9,7 @@ export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRe
}
// Read the current global role; tenant roles and stale JWT claims do not grant access.
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
.from(authUsers).where(and(eq(authUsers.id, req.user.user_id), isNull(authUsers.archived_at))).limit(1);
.from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1);
if (!user?.is_admin) {
reply.code(403).send({ error: "Admin access required" });
return null;

View File

@@ -302,20 +302,6 @@ export const buildTenantFullExport = async (
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
}
// Span children have no tenant column; include them through their scoped parent IDs.
const timeSpanIds = collectIds(tables.staff_time_spans || [], "id")
if (timeSpanIds.length) {
for (const table of ["staff_time_span_breaks", "staff_time_span_projects", "staff_time_span_history"]) {
if (columnsByTable.has(table)) {
addRows(tables, table, await loadRows(client, table, "span_id = any($1::uuid[])", [timeSpanIds]))
}
}
}
const legacyTimeIds = collectIds(tables.staff_time_entries || [], "id")
if (legacyTimeIds.length && columnsByTable.has("staff_time_entry_connects")) {
addRows(tables, "staff_time_entry_connects", await loadRows(client, "staff_time_entry_connects", "time_entry_id = any($1::uuid[])", [legacyTimeIds]))
}
const communicationRoomIds = collectIds(tables.communication_rooms || [], "id")
if (communicationRoomIds.length) {
addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds]))

View File

@@ -1,24 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { calculateAccountBalance, CASHBOOK_BANK_ID } from "../src/modules/bank-balances.service"
const syncedAt = new Date("2026-10-20T08:00:00Z")
const bank = { id: 1, name: "Giro", iban: "DE00", ownerName: null, bankId: "bank", datevNumber: "1200", balance: 1000.1, expired: false, syncedAt }
const cashbook = { ...bank, id: 2, name: "Kasse", iban: "CASH-1", bankId: CASHBOOK_BANK_ID, datevNumber: "1000", balance: 50 }
test("bank accounts report the synced balance and revert later statements for a past date", () => {
assert.equal(calculateAccountBalance(bank, 0, null).balance, 1000.1)
assert.equal(calculateAccountBalance(bank, 0, null).source, "bank_sync")
const past = calculateAccountBalance(bank, -200.05, "2026-10-01")
assert.equal(past.balance, 1200.15)
assert.equal(past.syncedBalance, 1000.1)
assert.equal(past.source, "bank_sync_reconstructed")
})
test("cashbooks add their bookings to the opening balance", () => {
const result = calculateAccountBalance(cashbook, 12.345, null)
assert.equal(result.type, "cashbook")
assert.equal(result.balance, 62.35)
assert.equal(result.openingBalance, 50)
assert.equal(result.iban, null)
})

View File

@@ -1,117 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import sharp from "sharp"
import Fastify from "fastify"
import { chatContent } from "../src/modules/chat-content"
import { CollaborationService } from "../src/modules/collaboration/service"
import communicationRoutes from "../src/routes/communication"
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
const picture = async () => ({ name: "Baustelle.png", dataUrl: `data:image/png;base64,${(await sharp({ create: { width: 80, height: 40, channels: 3, background: '#206050' } }).png().toBuffer()).toString('base64')}` })
test("chat content accepts emoji and image-only messages, strips metadata and rejects unsafe input", async () => {
assert.equal((await chatContent({ text: "Hallo 👋 ❤️" })).body, "Hallo 👋 ❤️")
const normalized = await chatContent({ image: await picture() })
assert.equal(normalized.body, "")
assert.equal(normalized.image?.width, 80)
const metadata = await sharp(Buffer.from(normalized.image!.dataUrl.split(',')[1], 'base64')).metadata()
assert.equal(metadata.format, 'webp')
assert.equal(metadata.exif, undefined)
for (const input of [{}, { text: ' '.repeat(5) }, { text: 'a'.repeat(10001) },
{ image: { dataUrl: 'https://example.test/tracker.png' } },
{ image: { dataUrl: `data:image/png;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>').toString('base64')}` } },
{ image: { dataUrl: 'data:image/png;base64,broken' } },
{ image: { dataUrl: 'a'.repeat(700001) } }]) await assert.rejects(chatContent(input))
})
test("native chat images and reactions follow tenant, membership, project and message boundaries", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase(), app = Fastify()
const owner = randomUUID(), colleague = randomUUID(), outsider = randomUUID(), room = randomUUID(), otherRoom = randomUUID()
try {
await fixture.pool.query("INSERT INTO tenants VALUES (1,'A',null),(2,'B',null)")
for (const [id, tenant] of [[owner, 1], [colleague, 1], [outsider, 2]]) {
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,$2)", [id, `${id}@test.example`])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES ($1,$2)", [tenant, id])
}
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived,chat_enabled) VALUES (1,1,'Project','{}',false,true)")
await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,type,archived,entity_id)
VALUES ($1,1,'project_1','Project','project',false,1),($2,1,'other','Other','room',false,null)`, [room, otherRoom])
await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2),($1,$3),($4,$2)", [room, owner, colleague, otherRoom])
app.decorate('db', fixture.db)
app.addHook('preHandler', async (req: any) => {
req.user = { user_id: req.headers['x-user'] || owner, tenant_id: Number(req.headers['x-tenant'] || 1), is_admin: true }
req.hasPermission = () => true
})
await app.register(communicationRoutes)
const base = '/communication/chat/rooms/project_1'
const sent = await app.inject({ method: 'POST', url: `${base}/messages`, payload: { image: await picture() } })
assert.equal(sent.statusCode, 200, sent.body)
const message = sent.json()
assert.equal(message.body, '')
assert.equal(message.image.width, 80)
assert.equal(message.image.dataUrl, undefined)
assert.equal((await app.inject(`${base}/messages`)).json().messages[0].image.dataUrl, undefined)
const image = await app.inject(`${base}/image?messageId=${message.id}`)
assert.equal(image.statusCode, 200, image.body)
assert.equal(image.headers['cache-control'], 'no-store')
assert.match(image.json().dataUrl, /^data:image\/webp;base64,/)
const react = (headers = {}, active = true, emoji = '👍') => app.inject({ method: 'PUT', url: `${base}/reactions`, headers, payload: { messageId: message.id, emoji, active } })
assert.equal((await react()).statusCode, 200)
assert.equal((await react()).json().reactions[message.id][0].count, 1)
assert.equal((await react({ 'x-user': colleague })).json().reactions[message.id][0].count, 2)
const removed = await react({}, false)
assert.deepEqual(removed.json().reactions[message.id], [{ emoji: '👍', count: 1, own: false }])
assert.equal((await react({}, true, '<script>')).statusCode, 400)
assert.equal((await app.inject(`/communication/chat/rooms/other/image?messageId=${message.id}`)).statusCode, 404)
assert.deepEqual((await app.inject(`/communication/chat/rooms/other/reactions?ids=${message.id}`)).json(), { reactions: {} })
for (const headers of [{ 'x-user': outsider }, { 'x-user': outsider, 'x-tenant': '2' }]) {
assert.ok([403, 404].includes((await app.inject({ url: `${base}/image?messageId=${message.id}`, headers })).statusCode))
assert.ok([403, 404].includes((await react(headers)).statusCode))
}
await fixture.pool.query('UPDATE projects SET chat_enabled=false WHERE id=1')
assert.equal((await app.inject(`${base}/image?messageId=${message.id}`)).statusCode, 404)
assert.equal((await app.inject(`${base}/reactions?ids=${message.id}`)).statusCode, 404)
assert.equal((await react()).statusCode, 404)
} finally { await app.close(); await fixture.close() }
})
test("subroom media respects history, guest read-only rights, revocation and idempotent sends", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase()
const service = new CollaborationService(fixture.db, { publicUrl: 'https://example.test', guestPepper: () => 'test', sendMail: async () => ({ success: true }) })
const ownerId = randomUUID()
try {
await fixture.pool.query("INSERT INTO tenants VALUES (1,'A',null)")
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,'owner@test.example',true)", [ownerId])
await fixture.pool.query('INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)', [ownerId])
const owner = await service.userContext(ownerId, 1)
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived) VALUES (1,1,'Project','{}',false)")
const room = await service.createRoom(owner, { name: 'Media', projectId: 1 })
const guest = await service.guest(owner, { name: 'Guest', email: 'guest@test.example' })
const old = await service.send(owner.principal, room.id, { clientId: randomUUID(), image: await picture() })
const invitation = await service.invite(owner, room.id, guest, {})
await service.accept(guest, invitation.id, invitation.token)
await assert.rejects(service.image(guest, room.id, { messageId: old.id }), /kein Zugriff/)
await assert.rejects(service.react(guest, room.id, { messageId: old.id, emoji: '👍', active: true }), /kein Zugriff/)
const command = { clientId: randomUUID(), text: '', image: await picture() }
const sent = await service.send(guest, room.id, command)
assert.equal((await service.send(guest, room.id, command)).id, sent.id)
await assert.rejects(service.send(guest, room.id, { ...command, image: { ...command.image, name: 'different.png' } }), /Auftrags-ID/)
assert.equal((await service.messages(guest, room.id)).messages[0].image.dataUrl, undefined)
assert.match((await service.image(guest, room.id, { messageId: sent.id })).dataUrl, /^data:image\/webp/)
await service.react(guest, room.id, { messageId: sent.id, emoji: '❤️', active: true })
await service.react(owner.principal, room.id, { messageId: sent.id, emoji: '❤️', active: true })
assert.deepEqual((await service.reactions(guest, room.id, { ids: `${old.id},${sent.id}` })).reactions[sent.id], [{ emoji: '❤️', count: 2, own: true }])
const member = (await service.management(owner, room.id)).members.find(m => m.principalId === guest.id)
await service.updateMember(owner, room.id, member.id, { role: 'reader', overviewAllowed: false })
await assert.rejects(service.react(guest, room.id, { messageId: sent.id, emoji: '❤️', active: false }), /nur lesbar/)
await assert.rejects(service.send(guest, room.id, { clientId: randomUUID(), image: await picture() }), /nur lesbar/)
await service.image(guest, room.id, { messageId: sent.id })
await service.updateRoom(owner, room.id, { status: 'archived' })
await assert.rejects(service.react(owner.principal, room.id, { messageId: sent.id, emoji: '👍', active: true }), /archiviert/)
await service.updateRoom(owner, room.id, { status: 'active' })
await service.revoke(owner, room.id, member.id)
await assert.rejects(service.image(guest, room.id, { messageId: sent.id }), /kein Zugriff/)
await assert.rejects(service.reactions(guest, room.id, { ids: sent.id }), /kein Zugriff/)
} finally { await fixture.close() }
})

View File

@@ -1,120 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import { readFile } from "node:fs/promises"
import Fastify from "fastify"
import communicationRoutes from "../src/routes/communication"
import resourceRoutes from "../src/routes/resources/main"
import { projectChatSettingError } from "../src/modules/communication-access"
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
test("changing the project chat switch requires project management permission and a boolean", () => {
const member = { user: {}, hasPermission: () => false }
const manager = { user: {}, hasPermission: (permission: string) => permission === "organisation.projects.write" }
assert.equal(projectChatSettingError(member, {}), null)
assert.equal(projectChatSettingError(member, { chatEnabled: true }), null)
assert.equal(projectChatSettingError(member, { chatEnabled: false }, false), null)
assert.equal(projectChatSettingError(member, { chatEnabled: false })?.statusCode, 403)
assert.equal(projectChatSettingError(member, { chatEnabled: true }, false)?.statusCode, 403)
assert.equal(projectChatSettingError(manager, { chatEnabled: false }), null)
assert.equal(projectChatSettingError({ user: { is_admin: true } }, { chatEnabled: true }, false), null)
for (const invalid of [null, "false", 0, {}]) assert.equal(projectChatSettingError(manager, { chatEnabled: invalid })?.statusCode, 400)
})
test("migration keeps existing and newly created project chats enabled", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase()
try {
await fixture.pool.query("ALTER TABLE projects DROP COLUMN chat_enabled")
await fixture.pool.query("INSERT INTO projects (id,tenant,name) VALUES (1,1,'Existing')")
await fixture.pool.query(await readFile(new URL("../db/migrations/0078_chat_visibility.sql", import.meta.url), "utf8"))
await fixture.pool.query("INSERT INTO projects (id,tenant,name) VALUES (2,1,'New')")
assert.deepEqual((await fixture.pool.query("SELECT chat_enabled FROM projects ORDER BY id")).rows, [{ chat_enabled: true }, { chat_enabled: true }])
} finally { await fixture.close() }
})
test("hidden rooms persist per user and tenant without changing room memberships", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase(["0078_chat_visibility.sql"]), app = Fastify()
const user = randomUUID(), other = randomUUID(), collaborationKey = `collaboration:${randomUUID()}`
try {
await fixture.pool.query("INSERT INTO tenants(id,name) VALUES (1,'A'),(2,'B')")
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'a@example.test'),($2,'b@example.test')", [user, other])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1),(1,$2)", [user, other])
app.decorate("db", fixture.db)
app.addHook("preHandler", async (req: any) => {
req.user = { tenant_id: Number(req.headers["x-tenant"] || 1), user_id: req.headers["x-user"] || user }
req.hasPermission = () => false
})
await app.register(communicationRoutes)
const url = "/communication/chat/room-preferences"
for (const roomKey of ["allgemein", "project_1", collaborationKey, collaborationKey]) {
const result = await app.inject({ method: "PUT", url, payload: { roomKey, hidden: true, userId: other, tenantId: 2 } })
assert.equal(result.statusCode, 200, result.body)
}
assert.deepEqual((await app.inject(url)).json().hiddenRoomKeys.sort(), ["allgemein", "project_1", collaborationKey].sort())
assert.deepEqual((await app.inject({ url, headers: { "x-user": other } })).json().hiddenRoomKeys, [])
assert.deepEqual((await app.inject({ url, headers: { "x-tenant": "2" } })).json().hiddenRoomKeys, [])
assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_room_members")).rows[0].count, "0")
await app.inject({ method: "PUT", url, payload: { roomKey: collaborationKey, hidden: false } })
assert.deepEqual((await app.inject(url)).json().hiddenRoomKeys.sort(), ["allgemein", "project_1"])
for (const payload of [{ roomKey: "allgemein", hidden: "false" }, { roomKey: "../secret", hidden: true }, { roomKey: "", hidden: true }]) {
assert.equal((await app.inject({ method: "PUT", url, payload })).statusCode, 400)
}
await fixture.pool.query("DELETE FROM auth_tenant_users WHERE tenant_id=1 AND user_id=$1", [user])
assert.equal((await app.inject(url)).statusCode, 403)
assert.equal((await app.inject({ method: "PUT", url, payload: { roomKey: "allgemein", hidden: false } })).statusCode, 403)
} finally { await app.close(); await fixture.close() }
})
test("disabling in the project form blocks list, provision, reads and writes until re-enabled", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase(), app = Fastify()
const user = randomUUID(), profile = randomUUID()
try {
await fixture.pool.query("INSERT INTO tenants(id,name) VALUES (1,'A'),(2,'B')")
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'a@example.test')", [user])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)", [user])
await fixture.pool.query("INSERT INTO auth_profiles(id,tenant_id,user_id) VALUES ($1,1,$2)", [profile, user])
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles) VALUES (1,1,'Project',ARRAY[$1]::uuid[]),(2,2,'Other tenant','{}')", [profile])
app.decorate("db", fixture.db)
app.addHook("preHandler", async (req: any) => {
req.user = { tenant_id: 1, user_id: user, is_admin: req.headers["x-admin"] === "true" }
req.hasPermission = (permission: string) => permission === "organisation.projects.write" && req.headers["x-manager"] === "true"
})
await app.register(communicationRoutes)
await app.register(resourceRoutes)
// The project editor loads the complete record and sends it back, including dates and other fields.
const update = async (chatEnabled: unknown, headers = { "x-manager": "true" }) => {
const loaded = await app.inject("/resource/projects/1/true")
assert.equal(loaded.statusCode, 200, loaded.body)
const result = await app.inject({ method: "PUT", url: "/resource/projects/1", headers, payload: { ...loaded.json(), chatEnabled } })
if (result.statusCode === 200) {
assert.equal(result.json().chatEnabled, chatEnabled)
assert.equal((await app.inject("/resource/projects/1/true")).json().chatEnabled, chatEnabled)
}
return result
}
const provision = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })
assert.equal(provision.statusCode, 200, provision.body)
const roomId = provision.json().id
await fixture.pool.query("INSERT INTO communication_messages(tenant_id,room_id,author_user_id,body) VALUES (1,$1,$2,'Preserved history')", [roomId, user])
assert.equal((await update(false, { "x-manager": "false" })).statusCode, 403)
assert.equal((await update("false")).statusCode, 400)
assert.equal((await app.inject({ method: "PUT", url: "/resource/projects/2", headers: { "x-manager": "true" }, payload: { chatEnabled: false } })).statusCode, 404)
assert.equal((await update(false)).statusCode, 200)
assert.deepEqual((await app.inject("/communication/chat/project-rooms")).json().rooms, [])
assert.equal((await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })).statusCode, 404)
for (const suffix of ["messages", "sync", "members"]) {
assert.equal((await app.inject(`/communication/chat/rooms/project_1/${suffix}`)).statusCode, 404)
assert.equal((await app.inject({ url: `/communication/chat/rooms/project_1/${suffix}`, headers: { "x-admin": "true" } })).statusCode, 404)
}
for (const suffix of ["messages", "read"]) assert.equal((await app.inject({ method: "POST", url: `/communication/chat/rooms/project_1/${suffix}`, payload: { text: "Rejected", messageId: 1 } })).statusCode, 404)
assert.deepEqual((await app.inject("/communication/chat/unread")).json().rooms, {})
assert.equal((await update(true, { "x-manager": "false" })).statusCode, 403)
assert.equal((await update(true)).statusCode, 200)
const history = await app.inject("/communication/chat/rooms/project_1/messages")
assert.equal(history.statusCode, 200, history.body)
assert.deepEqual(history.json().messages.map(message => message.body), ["Preserved history"])
assert.equal((await app.inject("/communication/chat/project-rooms")).json().rooms.length, 1)
const create = await app.inject({ method: "POST", url: "/resource/projects", payload: { id: 3, name: "Disabled", projectNumber: "P-3", chatEnabled: false } })
assert.equal(create.statusCode, 403)
} finally { await app.close(); await fixture.close() }
})

View File

@@ -1,54 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { collaborationConfig } from "../src/modules/collaboration/config"
const deployment = { API_BASE_URL: "https://fedeo.example/backend", ENCRYPTION_KEY: "a".repeat(64) }
test("existing HTTPS deployment enables federation without additional environment values", () => {
const config = collaborationConfig(deployment)
assert.equal(config.enabled, true)
assert.equal(config.apiUrl, deployment.API_BASE_URL)
assert.equal(config.publicUrl, "https://fedeo.example")
assert.equal(config.disabledReason, undefined)
assert.equal(collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "auto", FEDEO_FEDERATION_URL: "", FEDEO_PUBLIC_URL: "" }).enabled, true)
})
test("custom API paths are retained and instance addresses are canonicalized", () => {
const config = collaborationConfig({ ...deployment, API_BASE_URL: "https://FEDEO.example:443/application/api/" })
assert.equal(config.apiUrl, "https://fedeo.example/application/api")
assert.equal(config.publicUrl, "https://fedeo.example")
assert.equal(config.enabled, true)
})
test("explicit opt-out stays disabled even on a correctly configured deployment", () => {
const config = collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "false" })
assert.equal(config.enabled, false)
assert.match(config.disabledReason!, /Betreiber deaktiviert/)
})
test("optional gateway override does not move guest links away from the web application", () => {
const config = collaborationConfig({ ...deployment, FEDEO_FEDERATION_URL: "https://gateway.example/federation-api/" })
assert.equal(config.apiUrl, "https://gateway.example/federation-api")
assert.equal(config.publicUrl, "https://fedeo.example")
assert.equal(collaborationConfig({ ...deployment, FEDEO_PUBLIC_URL: "https://portal.example/app/" }).publicUrl, "https://portal.example/app")
})
test("local and incomplete deployments keep local collaboration available without federation", () => {
for (const API_BASE_URL of [undefined, "", "invalid", "http://localhost:3100", "https://fedeo.example:8443/backend", "https://user:password@fedeo.example/backend"]) {
const config = collaborationConfig({ ...deployment, API_BASE_URL })
assert.equal(config.enabled, false, API_BASE_URL)
assert.match(config.disabledReason!, /HTTPS-API_BASE_URL/)
}
for (const ENCRYPTION_KEY of [undefined, "", "invalid", "x".repeat(64)]) {
const config = collaborationConfig({ ...deployment, ENCRYPTION_KEY })
assert.equal(config.enabled, false)
assert.match(config.disabledReason!, /ENCRYPTION_KEY/)
}
})
test("forced activation reports invalid configuration instead of silently ignoring it", () => {
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true", API_BASE_URL: "http://localhost" }), /HTTPS/)
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true", ENCRYPTION_KEY: "invalid" }), /ENCRYPTION_KEY/)
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "typo" }), /auto, true oder false/)
assert.equal(collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true" }).enabled, true)
})

View File

@@ -1,8 +1,7 @@
import assert from "node:assert/strict"
import test from "node:test"
import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto"
import { generateKeyPairSync, randomUUID } from "node:crypto"
import Fastify from "fastify"
import sharp from "sharp"
import { CollaborationService } from "../src/modules/collaboration/service"
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
import { instanceUrl, publicAddress, signedHeaders, verifyHeaders, Transport } from "../src/modules/collaboration/transport"
@@ -105,38 +104,6 @@ test("two independent instances exchange invitations and messages through signed
const history = (await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages
assert.deepEqual(history.map((m: any) => m.body), ["Unpolled owner message", command.text])
})
await t.test("image-only messages and reactions cross signed routes, including retries and history restrictions", async () => {
const mediaRoom = await a.service.createRoom(a.ctx, { name: "Media" })
const image = { name: "Foto.webp", dataUrl: `data:image/webp;base64,${(await sharp(randomBytes(700 * 500 * 3), { raw: { width: 700, height: 500, channels: 3 } }).webp({ quality: 85 }).toBuffer()).toString('base64')}` }
assert.ok(image.dataUrl.length > 128 * 1024)
const old = await a.service.send(a.ctx.principal, mediaRoom.id, { clientId: randomUUID(), image })
const invitation = await a.service.invite(a.ctx, mediaRoom.id, contact, {})
const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${invitation.id}`])).rows[0]
await a.federation.deliver(job)
const binding = (await b.federation.list(b.ctx)).invitations.find(i => i.name === "Media")!.id
await b.federation.accept(b.ctx, binding)
await assert.rejects(b.federation.memberRequest(b.ctx, binding, "image", { messageId: old.id }), /kein Zugriff/)
const command = { clientId: randomUUID(), image }
loseReply = true
assert.equal((await b.federation.memberRequest(b.ctx, binding, "send", command)).result.pending, true)
const confirmed = (await b.federation.memberRequest(b.ctx, binding, "send", command)).result
assert.ok(confirmed.seq)
assert.equal(confirmed.body, "")
assert.equal(confirmed.image.dataUrl, undefined)
assert.equal((await a.service.messages(a.ctx.principal, mediaRoom.id)).messages.length, 2)
const photo = (await b.federation.memberRequest(b.ctx, binding, "image", { messageId: confirmed.id })).result
assert.match(photo.dataUrl, /^data:image\/webp;base64,/)
await b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: true })
await a.service.react(a.ctx.principal, mediaRoom.id, { messageId: confirmed.id, emoji: "🎉", active: true })
const reactions = (await b.federation.memberRequest(b.ctx, binding, "reactions", { ids: confirmed.id })).result.reactions
assert.deepEqual(reactions[confirmed.id], [{ emoji: "🎉", count: 2, own: true }])
await b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: false })
assert.deepEqual((await a.service.reactions(a.ctx.principal, mediaRoom.id, { ids: confirmed.id })).reactions[confirmed.id], [{ emoji: "🎉", count: 1, own: true }])
const member = (await a.service.management(a.ctx, mediaRoom.id)).members.find(m => m.principalId === contact.id)
await a.service.revoke(a.ctx, mediaRoom.id, member.id)
await assert.rejects(b.federation.memberRequest(b.ctx, binding, "image", { messageId: confirmed.id }), /kein Zugriff/)
await assert.rejects(b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: true }))
})
await t.test("only explicit overview is live-shared; offline cache omits it", async () => {
await a.service.updateRoom(a.ctx, room.id, { overview: { title: "Freigegeben", description: "Treffpunkt Tor 2" } })
const live = await b.federation.memberRequest(b.ctx, bindingId, "room")

View File

@@ -1,201 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import { readFile } from "node:fs/promises"
import Fastify from "fastify"
import { eq } from "drizzle-orm"
import { notificationsItems } from "../db/schema"
import { CollaborationService } from "../src/modules/collaboration/service"
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
import { CollaborationNotifications } from "../src/modules/collaboration/notifications"
import type { Transport } from "../src/modules/collaboration/transport"
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
const MESSAGE = "collaboration.message.new", INVITATION = "collaboration.room.invited"
test("collaboration notifications use real preferences, transactions and signed federation routes", { skip: !testDatabaseUrl }, async t => {
const servers = new Map<string, ReturnType<typeof Fastify>>()
let offline = false
const transport: Transport = async (url, method, headers, body) => {
if (offline) throw Object.assign(new Error("Offline"), { statusCode: 503 })
const origin = [...servers.keys()].find(value => url.startsWith(value + "/"))!
const response = await servers.get(origin)!.inject({ method, url: url.slice(origin.length), headers, ...(body ? { payload: body } : {}) })
if (response.statusCode >= 400) throw Object.assign(new Error(response.json().message), { statusCode: response.statusCode })
return response.json()
}
const make = async (name: string) => {
const fixture = await collaborationDatabase(["0075_project_collaboration.sql", "0076_collaboration_federation.sql"])
const original = await readFile(new URL("../db/migrations/0000_brief_dark_beast.sql", import.meta.url), "utf8")
for (const type of ["notification_channel", "notification_status", "notification_severity"]) {
const definition = original.match(new RegExp(`CREATE TYPE "public"\\."${type}" AS ENUM\\([^;]+;`))![0]
await fixture.pool.query(definition.replace('"public".', ""))
}
for (const table of ["notifications_event_types", "notifications_items", "notifications_preferences", "notifications_preferences_defaults"]) {
await fixture.pool.query(original.match(new RegExp(`CREATE TABLE "${table}" \\([\\s\\S]*?\\n\\);`))![0])
}
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences(tenant_id,user_id,event_type,channel)")
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences_defaults(tenant_id,event_key,channel)")
await fixture.pool.query(await readFile(new URL("../db/migrations/0083_collaboration_notifications.sql", import.meta.url), "utf8"))
const userId = randomUUID()
await fixture.pool.query("INSERT INTO tenants VALUES (1,$1,null,null),(2,'Other tenant',null,null)", [name])
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,$2,true)", [userId, `${name}@example.test`])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1)", [userId])
const service = new CollaborationService(fixture.db, { publicUrl: `https://${name}.example`, guestPepper: () => "a".repeat(64), sendMail: async () => ({ success: true }) })
const federation = new FederationService(service, { apiUrl: `https://${name}.example/backend`, enabled: true, transport,
seal: value => ({ key: value }), unseal: value => value.key })
service.options.changed = (id, db) => federation.changed(id, db)
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
const app = Fastify()
app.decorate("db", fixture.db)
const notifications = new CollaborationNotifications(app, service, federation)
service.options.notifications = notifications
const delivered: any[] = []
const delivery = notifications.delivery.bind(notifications)
// Keep persistence and preference resolution real; replace only external channel delivery.
notifications.delivery = db => {
const sender = delivery(db)
sender.deliver = async item => {
delivered.push(item)
await db.update(notificationsItems).set({ status: "sent", sentAt: new Date() }).where(eq(notificationsItems.id, item.id))
return { success: true, id: item.id, channel: item.channel }
}
return sender
}
await app.register(federationRoutes, { federation })
await app.ready()
servers.set(federation.origin, app)
return { ...fixture, service, federation, notifications, app, delivered, ctx: await service.userContext(userId, 1), other: await service.userContext(userId, 2),
items: async () => (await fixture.pool.query("SELECT * FROM notifications_items ORDER BY created_at,id")).rows }
}
const a = await make("alpha"), b = await make("beta")
try {
await a.federation.settings(a.ctx, { origin: b.federation.origin, active: true })
await b.federation.settings(b.ctx, { origin: a.federation.origin, active: true })
const contact = await a.federation.resolveContact(a.ctx, b.federation.contact(b.ctx.principal))
const room = await a.service.createRoom(a.ctx, { name: "Gemeinsamer Chat" })
const before = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Private history" })
let bindingId: string
const jobFor = async (invitationId: string) => (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${invitationId}`])).rows[0]
const itemsFor = async (instance: typeof a, id: string) => (await instance.items()).filter(item => item.payload.messageId === id)
const enable = async (instance: typeof a, tenantId = 1, enabled = true) => instance.notifications.delivery(instance.db).savePreferences(tenantId, instance.ctx.userId,
["email", "push"].map(channel => ({ eventType: MESSAGE, channel, enabled })))
await t.test("new matrix events have quiet message defaults and per-tenant overrides", async () => {
const preferences = await b.notifications.delivery(b.db).preferences(1, b.ctx.userId)
assert.deepEqual(preferences.events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
assert.deepEqual(preferences.events.find(e => e.eventKey === INVITATION)!.channels, { email: false, push: true })
await enable(a); await enable(b)
assert.deepEqual((await b.notifications.delivery(b.db).preferences(2, b.ctx.userId)).events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
})
await t.test("signed invitations notify only their named recipient, once despite redelivery", async () => {
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
const job = await jobFor(invitation.id)
await a.federation.deliver(job)
bindingId = (await b.federation.list(b.ctx)).invitations[0].id
await a.pool.query("UPDATE collaboration_outbox SET status='pending' WHERE id=$1", [job.id])
await a.federation.deliver(job)
const items = await b.items()
assert.equal(items.length, 2)
assert.deepEqual(items.map(i => i.channel).sort(), ["inapp", "push"])
assert.ok(items.every(i => i.user_id === b.ctx.userId && Number(i.tenant_id) === 1 && i.event_type === INVITATION))
assert.equal((await a.items()).length, 0)
await b.notifications.tick()
assert.equal(b.delivered.length, 2)
await b.federation.accept(b.ctx, bindingId)
assert.equal((await itemsFor(b, before.id)).length, 0)
})
await t.test("new remote messages notify once with the local room link, including simultaneous syncs", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Hallo Beta" })
await Promise.all([b.federation.memberRequest(b.ctx, bindingId, "messages"), b.federation.memberRequest(b.ctx, bindingId, "messages")])
const items = await itemsFor(b, message.id)
assert.equal(items.length, 3)
assert.ok(items.every(i => i.message.includes("Hallo Beta") && i.payload.link === `/communication/chat?room=collaboration%3A${bindingId}`))
assert.equal((await itemsFor(a, message.id)).length, 0)
await b.federation.memberRequest(b.ctx, bindingId, "messages", { before: message.seq + 1 })
assert.equal((await itemsFor(b, message.id)).length, 3)
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 3)
})
await t.test("remote replies notify the owner's local members, never the sender or retry twice", async () => {
const input = { clientId: randomUUID(), text: "Hallo Alpha" }
const reply = (await b.federation.memberRequest(b.ctx, bindingId, "send", input)).result
await b.federation.memberRequest(b.ctx, bindingId, "send", input)
await b.federation.memberRequest(b.ctx, bindingId, "messages")
assert.equal((await itemsFor(a, reply.id)).length, 3)
assert.equal((await itemsFor(b, reply.id)).length, 0)
assert.ok((await itemsFor(a, reply.id)).every(i => Number(i.tenant_id) === 1 && i.user_id === a.ctx.userId))
await a.notifications.tick()
})
await t.test("reading before delivery suppresses email and push, and new preferences take effect", async () => {
const read = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Already read" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
await b.federation.memberRequest(b.ctx, bindingId, "read", { seq: read.seq })
await b.notifications.tick()
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === read.id).map(i => i.channel), ["inapp"])
const muted = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Muted before delivery" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
await enable(b, 1, false)
await b.notifications.tick()
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === muted.id).map(i => i.channel), ["inapp"])
await enable(b)
})
await t.test("offline jobs survive a worker restart and concurrent workers deliver each item once", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Wait for access confirmation" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
offline = true
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
assert.ok((await itemsFor(b, message.id)).every(i => i.status === "queued"))
offline = false
const restarted = new CollaborationNotifications(b.app, b.service, b.federation)
restarted.delivery = b.notifications.delivery
await Promise.all([b.notifications.tick(), restarted.tick()])
const delivered = b.delivered.filter(i => i.payload.messageId === message.id)
assert.equal(delivered.length, 3)
assert.equal(new Set(delivered.map(i => i.id)).size, 3)
})
await t.test("revoked access cancels queued remote messages and reinvitation cannot replay old ones", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Revoked before delivery" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id)
await a.service.revoke(a.ctx, room.id, member.id)
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
assert.equal((await itemsFor(b, message.id)).length, 0)
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
await a.federation.deliver(await jobFor(invitation.id))
await b.federation.accept(b.ctx, bindingId)
await b.notifications.tick()
assert.equal((await itemsFor(b, message.id)).length, 0)
})
await t.test("local cross-tenant invitations and messages use recipient scope and honor expiry", async () => {
const local = await a.service.createRoom(a.ctx, { name: "Tenant exchange" })
const invitation = await a.service.invite(a.ctx, local.id, a.other.principal, {})
const invites = (await a.items()).filter(i => i.payload.invitationId === invitation.id)
assert.equal(invites.length, 2)
assert.ok(invites.every(i => Number(i.tenant_id) === 2))
await a.service.accept(a.other.principal, invitation.id)
const first = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Only tenant two" })
assert.deepEqual((await itemsFor(a, first.id)).map(i => [Number(i.tenant_id), i.channel]), [[2, "inapp"]])
await a.pool.query("UPDATE collaboration_members SET expires_at=now()-interval '1 second' WHERE room_id=$1 AND principal_id=$2", [local.id, a.other.principal.id])
await a.notifications.tick()
assert.equal(a.delivered.filter(i => i.payload.messageId === first.id).length, 0)
const second = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Expired member" })
assert.equal((await itemsFor(a, second.id)).length, 0)
})
await t.test("message and notification records roll back together", async () => {
const hook = a.notifications.localMessage.bind(a.notifications)
a.notifications.localMessage = async (...args) => { await hook(...args); throw new Error("Rollback after enqueue") }
const clientId = randomUUID(), count = (await a.items()).length
try {
await assert.rejects(b.federation.call(1, a.federation.origin, "messages.send", { actorId: b.ctx.principal.id, roomId: room.id,
input: { clientId, text: "Must roll back", revision: Number((await b.federation.binding(b.ctx, bindingId)).revision) } }), /Rollback/)
} finally { a.notifications.localMessage = hook }
assert.equal((await a.items()).length, count)
assert.equal((await a.pool.query("SELECT id FROM collaboration_messages WHERE client_id=$1", [clientId])).rowCount, 0)
})
} finally {
await a.app.close(); await b.app.close()
await a.close(); await b.close()
}
})

View File

@@ -4,7 +4,6 @@ import { randomUUID } from "node:crypto"
import Fastify from "fastify"
import jwt from "jsonwebtoken"
import cookie from "@fastify/cookie"
import sharp from "sharp"
import authPlugin from "../src/plugins/auth"
import { secrets } from "../src/utils/secrets"
import { collaborationRoutes, collaborationGuestRoutes } from "../src/routes/collaboration"
@@ -58,19 +57,7 @@ test("HTTP guest sessions never authorize normal APIs or other subrooms", { skip
assert.equal(sent.statusCode, 200, sent.body)
const stored = await fixture.pool.query("SELECT p.kind FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.id=$1", [sent.json().id])
assert.equal(stored.rows[0].kind, "guest")
const image = { name: "Gastbild.png", dataUrl: `data:image/png;base64,${(await sharp({ create: { width: 2, height: 2, channels: 3, background: 'green' } }).png().toBuffer()).toString('base64')}` }
const photo = await app.inject({ method: "POST", url: `/guest/api/rooms/${roomId}/messages`, headers: guestHeaders,
payload: { clientId: randomUUID(), image } })
assert.equal(photo.statusCode, 200, photo.body)
assert.equal(photo.json().image.dataUrl, undefined)
const imagePath = `/guest/api/rooms/${roomId}/image?messageId=${photo.json().id}`
assert.equal((await app.inject({ url: imagePath, headers: guestHeaders })).statusCode, 200)
const reactionPath = `/guest/api/rooms/${roomId}/reactions`
assert.equal((await app.inject({ method: "PUT", url: reactionPath, headers: guestHeaders,
payload: { messageId: photo.json().id, emoji: '👍', active: true } })).statusCode, 200)
assert.equal((await app.inject({ url: `${reactionPath}?ids=${photo.json().id}`, headers: guestHeaders })).json().reactions[photo.json().id][0].own, true)
assert.equal((await app.inject({ method: "POST", url: "/guest/api/logout", headers: guestHeaders })).statusCode, 200)
assert.equal((await app.inject({ url: "/guest/api/rooms", headers: guestHeaders })).statusCode, 401)
assert.equal((await app.inject({ url: imagePath, headers: guestHeaders })).statusCode, 401)
} finally { secrets.JWT_SECRET = previousSecret; await app.close(); await fixture.close() }
})

View File

@@ -1,126 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import cookie from "@fastify/cookie"
import bcrypt from "bcrypt"
import jwt from "jsonwebtoken"
import { drizzle } from "drizzle-orm/pg-proxy"
import authRoutes from "../src/routes/auth/auth"
import meRoutes from "../src/routes/auth/me"
import { secrets } from "../src/utils/secrets"
import { getPortalLoginUrl } from "../src/utils/portalUrl"
test("portal invitations always use a configured frontend login URL", () => {
const defaults = { apiBaseUrl: "https://fedeo.example/backend" }
assert.equal(getPortalLoginUrl(defaults), "https://fedeo.example/login")
assert.equal(getPortalLoginUrl({ ...defaults, portalDomain: " kunden.example " }), "https://kunden.example/login")
assert.equal(getPortalLoginUrl({ ...defaults, portalDomain: "https://kunden.example/" }), "https://kunden.example/login")
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "https://web.example/fedeo/" }), "https://web.example/fedeo/login")
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "http://localhost:3000/" }), "http://localhost:3000/login")
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "javascript:alert(1)" }), "https://fedeo.example/login")
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "https://user:password@web.example/" }), "https://fedeo.example/login")
assert.throws(() => getPortalLoginUrl({}), /öffentliche FEDEO-Adresse/)
})
const userId = "00000000-0000-4000-8000-000000000001"
const profileId = "00000000-0000-4000-8000-000000000002"
const passwordHash = bcrypt.hashSync("portal-password", 4)
function selectedRow(sql: string, values: Record<string, any>) {
return sql.slice(7, sql.indexOf(" from ")).split(", ").map(column => {
const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1]
return values[name!] ?? null
})
}
async function fixture(t: any, memberships: Array<[number, number | null]>, domainTenantId?: number) {
const refreshTenants: Array<number | null> = []
const db = drizzle(async (sql, params) => {
if (sql.includes('from "auth_users"')) {
return { rows: [selectedRow(sql, {
id: userId, email: "customer@example.com", password_hash: passwordHash,
must_change_password: true, is_admin: false,
})] }
}
if (sql.includes('from "auth_tenant_users"') && sql.includes('"auth_profiles"')) {
assert.ok(params.includes(userId))
assert.match(sql, /"auth_profiles"\."tenant_id" = "auth_tenant_users"\."tenant_id"/)
assert.match(sql, /"auth_profiles"\."active" =/)
assert.ok(params.includes(true))
return { rows: memberships }
}
if (sql.startsWith('insert into "auth_refresh_tokens"')) {
refreshTenants.push(params[1] as number | null)
return { rows: [] }
}
if (sql.includes('from "auth_tenant_users"')) {
return { rows: memberships.map(([id]) => selectedRow(sql, { id, name: "Test tenant" })) }
}
if (sql.includes('from "auth_profiles"')) {
const membership = memberships.find(([id]) => params.includes(id))
return { rows: membership ? [selectedRow(sql, {
id: profileId, user_id: userId, tenant_id: membership[0],
customer_for_portal: membership[1], active: true,
})] : [] }
}
if (sql.includes('from "auth_profile_branches"') || sql.includes('from "auth_user_roles"')) return { rows: [] }
throw new Error(`Unexpected query: ${sql}`)
})
secrets.JWT_SECRET = "customer-portal-test-secret"
const app = Fastify()
app.decorate("db", db as any)
app.addHook("preHandler", async (req: any) => {
if (domainTenantId) req.tenant = { id: String(domainTenantId) }
if (req.headers.authorization) req.user = jwt.verify(req.headers.authorization.slice(7), secrets.JWT_SECRET)
})
await app.register(cookie)
await app.register(authRoutes)
await app.register(meRoutes)
t.after(() => app.close())
const login = (password = "portal-password") => app.inject({
method: "POST", url: "/auth/login", payload: { email: "customer@example.com", password },
})
return { app, login, refreshTenants }
}
test("shared-address login scopes both tokens and loads the customer's portal profile", async t => {
const f = await fixture(t, [[7, 123]])
const response = await f.login()
assert.equal(response.statusCode, 200, response.body)
const session = response.json()
assert.equal((jwt.verify(session.token, secrets.JWT_SECRET) as any).tenant_id, 7)
assert.deepEqual(f.refreshTenants, [7])
assert.ok(session.refreshToken)
const me = await f.app.inject({ url: "/me", headers: { authorization: `Bearer ${session.token}` } })
assert.equal(me.statusCode, 200, me.body)
assert.equal(me.json().activeTenant, 7)
assert.equal(me.json().profile.customer_for_portal, 123)
assert.equal(me.json().user.must_change_password, true)
})
test("internal and ambiguous accounts retain tenant selection", async t => {
for (const memberships of [[], [[7, null]], [[7, 123], [8, 456]]] as Array<Array<[number, number | null]>>) {
const f = await fixture(t, memberships)
const response = await f.login()
assert.equal(response.statusCode, 200, response.body)
assert.equal((jwt.verify(response.json().token, secrets.JWT_SECRET) as any).tenant_id, null)
assert.deepEqual(f.refreshTenants, [null])
}
})
test("a portal domain selects only a tenant the user belongs to", async t => {
const f = await fixture(t, [[7, 123], [8, 456]], 8)
const response = await f.login()
assert.equal(response.statusCode, 200, response.body)
assert.equal((jwt.verify(response.json().token, secrets.JWT_SECRET) as any).tenant_id, 8)
assert.deepEqual(f.refreshTenants, [8])
const other = await fixture(t, [[7, 123]], 99)
assert.equal((await other.login()).statusCode, 403)
assert.deepEqual(other.refreshTenants, [])
})
test("invalid credentials never create a portal session", async t => {
const f = await fixture(t, [[7, 123]])
assert.equal((await f.login("wrong-password")).statusCode, 401)
assert.deepEqual(f.refreshTenants, [])
})

View File

@@ -24,8 +24,8 @@ export async function collaborationDatabase(migrations: string[] = []) {
const columns = Object.values(getTableColumns(table)).map((column: any) => {
let suffix = column.primary ? " PRIMARY KEY" : ""
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
if (column.columnType === "PgTimestamp" && column.hasDefault) suffix += " DEFAULT now()"
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
if (column.getSQLType() === "boolean") suffix += " DEFAULT false"
return `"${column.name}" ${column.getSQLType()}${suffix}`
})
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`)
@@ -33,10 +33,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, name text, locked text, locked_by_export_job_id uuid)`)
await pool.query(`CREATE UNIQUE INDEX ON communication_rooms (tenant_id, key)`)
await pool.query(await readFile(new URL("../../db/migrations/0070_native_communication_chat.sql", import.meta.url), "utf8"))
const steps = [...migrations]
if (!steps.includes("0075_project_collaboration.sql")) steps.unshift("0075_project_collaboration.sql")
if (!steps.includes("0081_chat_media_reactions.sql")) steps.push("0081_chat_media_reactions.sql")
for (const name of steps) {
for (const name of migrations) {
await pool.query(await readFile(new URL(`../../db/migrations/${name}`, import.meta.url), "utf8"))
}
return { db, pool, close: async () => {

View File

@@ -1,48 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
applyAccountCostCentreAssignments,
normalizeAccountCostCentreAssignments,
} from "../src/modules/incoming-invoice-cost-centres.service"
import { mcpToolMap } from "../src/mcp/registry"
const CENTRE = "8f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"
test("changes only requested account cost centres", () => {
const accounts = [
{ account: 1, amountNet: 100, taxType: "19", costCentre: "old-1" },
{ account: 2, amountNet: 50, taxType: "7", costCentre: "old-2" },
]
assert.deepEqual(applyAccountCostCentreAssignments(accounts, [
{ index: 1, costcentre: CENTRE },
]), [
accounts[0],
{ account: 2, amountNet: 50, taxType: "7", costCentre: CENTRE },
])
})
test("rejects assignments for unknown invoice positions", () => {
assert.throws(
() => applyAccountCostCentreAssignments([{ account: 1 }], [{ index: 3, costcentre: null }]),
/Position 4.*nicht gefunden/,
)
})
test("validates cost centre assignments", () => {
assert.throws(() => normalizeAccountCostCentreAssignments([]), /Mindestens eine/)
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: -1, costcentre: null }]), /gültigen index/)
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: "x" }]), /Ungültige Kostenstelle/)
assert.throws(
() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: null }, { index: 0, costcentre: CENTRE }]),
/mehrfach/,
)
assert.deepEqual(normalizeAccountCostCentreAssignments([{ index: 0, costcentre: CENTRE }]), [{ index: 0, costcentre: CENTRE }])
})
test("registers a dedicated incoming invoice cost centre tool", () => {
const tool = mcpToolMap.get("accounting.incoming_invoices.cost_centres.update")
assert.deepEqual(tool?.requiredPermissions, ["accounting.incoming_invoices.write"])
assert.deepEqual(tool?.inputSchema.required, ["id", "accountCostCentres"])
})

View File

@@ -1,45 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { prepareIncomingInvoices } from "../src/modules/cron/prepareIncomingInvoices"
function server(tenantLookups: { count: number }) {
let release: () => void = () => undefined
const gate = new Promise<void>((resolve) => { release = resolve })
const db: any = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => {
tenantLookups.count += 1
await gate
return []
},
}),
}),
}),
}
const log = { error: () => undefined, info: () => undefined, warn: () => undefined }
return { server: { db, log } as any, release: () => release() }
}
test("start runs the preparation in the background and does not start a second run", async () => {
const lookups = { count: 0 }
const { server: srv, release } = server(lookups)
const service = prepareIncomingInvoices(srv)
const first = service.start(7)
const second = service.start(7)
assert.equal(first.running, true)
assert.equal(second.startedAt, first.startedAt)
assert.equal(service.status(8).running, false)
release()
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
const status = service.status(7)
assert.equal(lookups.count, 1)
assert.equal(status.running, false)
assert.ok(status.finishedAt)
assert.equal(status.result?.error, "Mandant nicht gefunden")
})

View File

@@ -1,99 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getTableColumns, getTableName } from "drizzle-orm"
import { PgDialect } from "drizzle-orm/pg-core"
import * as schema from "../db/schema"
import { mcpToolMap } from "../src/mcp/registry"
import { archiveEntities, prepareArchiveRestoreInput } from "../src/mcp/tools/archive"
import { asToolResult } from "../src/mcp/result"
const tenantId = 7
const userId = "00000000-0000-4000-8000-000000000001"
const uuid = "00000000-0000-4000-8000-000000000002"
const dialect = new PgDialect()
/** Minimal Drizzle stand-in which evaluates equality predicates, including tenant filters. */
function fixture(initial: Record<string, any[]> = {}, listRows: any[] = []) {
const state = structuredClone(initial)
const rowsFor = (table: any) => state[getTableName(table)] ||= []
const matches = (table: any, where: any, row: any) => {
const query = dialect.sqlToQuery(where)
const columns = getTableColumns(table) as Record<string, any>
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
return [...query.sql.matchAll(/"\w+"\."(\w+)" = \$(\d+)/g)]
.every((match) => row[keyFor(match[1])] === query.params[Number(match[2]) - 1])
}
const listed: string[] = []
const db: any = {
transaction: async (callback: any) => callback(db),
select: () => ({ from: (table: any) => {
let condition: any
const query: any = {
where: (where: any) => { condition = where; return query },
orderBy: () => query,
offset: () => query,
limit: () => query,
for: () => query,
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
if (listRows.length) { listed.push(dialect.sqlToQuery(condition).sql); return listRows }
return rowsFor(table).filter((row) => matches(table, condition, row)).map((row) => ({ ...row }))
}).then(resolve, reject),
}
return query
} }),
insert: (table: any) => ({ values: async (data: any) => { rowsFor(table).push(data) } }),
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({
returning: async () => rowsFor(table).filter((row) => matches(table, where, row)).map((row) => ({ ...Object.assign(row, data) })),
}) }) }),
}
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
return { context, rows: rowsFor, listed }
}
const call = async (context: any, name: string, args: Record<string, any>) => await mcpToolMap.get(`archive.entities.${name}`)!.handler(context, args) as any
for (const [key, entity] of Object.entries(archiveEntities)) {
test(`${key}: restore unarchives within the tenant and records history`, async () => {
const id = entity.uuid ? uuid : 1
const f = fixture({
[getTableName(entity.table)]: [{ id, tenant: tenantId, archived: true }],
// Restoring documents requires an internal (non-portal) profile.
auth_profiles: [{ tenant_id: tenantId, user_id: userId, customer_for_portal: null }],
})
const result = await call(f.context, "restore", { entity: key, id })
assert.deepEqual(asToolResult(result).structuredContent?.success, true)
assert.equal(result.record.archived, undefined)
const [row] = f.rows(entity.table)
assert.equal(row.archived, false)
assert.equal(row.updatedBy ?? row.updated_by, userId)
await assert.rejects(call(f.context, "restore", { entity: key, id }), /nicht archiviert/)
})
}
test("restore rejects foreign tenants, unknown entities, invalid IDs and missing write permission", async () => {
const f = fixture({ customers: [{ id: 1, tenant: 8, archived: true }], tasks: [{ id: 2, tenant: tenantId, archived: true }] })
await assert.rejects(call(f.context, "restore", { entity: "customers", id: 1 }), /nicht gefunden/)
assert.equal(f.rows(schema.customers)[0].archived, true)
await assert.rejects(call(f.context, "restore", { entity: "accounts", id: 1 }), /unbekannte Entität/)
assert.throws(() => prepareArchiveRestoreInput({ entity: "cost_centres", id: 1 }), /UUID/)
assert.throws(() => prepareArchiveRestoreInput({ entity: "tasks", id: "abc" }), /ganze Zahl/)
f.context.isAdmin = false
f.context.permissions = ["organisation.tasks.read"]
await assert.rejects(call(f.context, "restore", { entity: "tasks", id: 2 }), /Fehlende Berechtigung.*organisation\.tasks\.write/)
f.context.permissions.push("organisation.tasks.write")
await call(f.context, "restore", { entity: "tasks", id: 2 })
assert.equal(f.rows(schema.tasks)[0].archived, false)
})
test("list returns archived rows visibly and requires the read permission", async () => {
const f = fixture({}, [{ id: 1, tenant: tenantId, archived: true, name: "Alt" }])
const result = asToolResult(await call(f.context, "list", { entity: "customers", search: "Al" }))
assert.deepEqual(result.structuredContent?.rows, [{ id: 1, tenant: tenantId, name: "Alt", isArchived: true }])
assert.match(f.listed[0], /"customers"\."archived" = \$2/)
f.context.isAdmin = false
f.context.permissions = []
await assert.rejects(call(f.context, "list", { entity: "customers" }), /masterdata\.customers\.read/)
})
test("restore is advertised as a write tool", () => {
assert.equal(mcpToolMap.get("archive.entities.restore")!.readOnly, false)
})

View File

@@ -1,219 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { Pool } from "pg"
import { drizzle } from "drizzle-orm/node-postgres"
import { eq, getTableColumns, getTableName } from "drizzle-orm"
import * as schema from "../db/schema"
import { mcpToolMap, mcpTools } from "../src/mcp/registry"
import { assertToolPermission } from "../src/mcp/authz"
import { asToolResult } from "../src/mcp/result"
import { eventDateTimeField, prepareEventInput, calendarTools } from "../src/mcp/tools/calendar"
import { outgoingDocumentStatus, outgoingStatusTools } from "../src/mcp/tools/outgoingStatus"
const execute = promisify(execFile)
const userId = "00000000-0000-4000-8000-000000000001"
const invoice = { id: 1, state: "Gebucht", type: "invoices", rows: [{ quantity: 1, price: 100, taxPercent: 19 }], usedAdvanceInvoices: [] }
test("registers read-only document status and permission-protected calendar tools", () => {
assert.equal(new Set(mcpTools.map(tool => tool.name)).size, mcpTools.length)
for (const tool of [...calendarTools, ...outgoingStatusTools]) {
assert.equal(mcpToolMap.get(tool.name), tool)
assert.throws(() => assertToolPermission({ isAdmin: false, permissions: [] } as any, tool), { statusCode: 403 })
assert.doesNotThrow(() => assertToolPermission({ isAdmin: false, permissions: tool.requiredPermissions } as any, tool))
}
for (const tool of outgoingStatusTools) assert.deepEqual(tool.requiredPermissions, ["accounting.outgoing_documents.read"])
for (const action of ["create", "update", "archive"]) {
assert.deepEqual(mcpToolMap.get(`organisation.events.${action}`)!.requiredPermissions, ["organisation.events.write"])
}
})
test("payment status handles drafts, partial payments, credits and advance invoices", () => {
const status = (document: any, paid: number, cancellations: any[] = [], advances: any[] = []) => outgoingDocumentStatus(document, paid, cancellations, advances)
assert.equal(status(invoice, 0).paymentStatus, "Offen")
assert.equal(status(invoice, 50).paymentStatus, "Teilbezahlt")
assert.equal(status(invoice, 119).paymentStatus, "Bezahlt")
assert.equal(status(invoice, 120).paymentStatus, "Überbezahlt")
assert.equal(status({ ...invoice, state: "Entwurf" }, 119).paymentStatus, null)
assert.equal(status({ ...invoice, type: "quotes" }, 0).paymentStatus, null)
const cancelled = status(invoice, 0, [{ id: 2, documentNumber: "ST-2" }])
assert.equal(cancelled.cancelled, true)
assert.equal(cancelled.paymentStatus, null)
assert.deepEqual(cancelled.cancellationDocuments, [{ id: 2, documentNumber: "ST-2" }])
const advance = { id: 3, rows: [{ price: 20, taxPercent: 19, advanceInvoiceData: {} }] }
assert.equal(status({ ...invoice, usedAdvanceInvoices: [3] }, 95.2, [], [advance]).paymentStatus, "Bezahlt")
assert.equal(status({ ...invoice, rows: [{ quantity: 1, price: -100, taxPercent: 19 }] }, -50).paymentStatus, "Teilbezahlt")
assert.equal(status({ ...invoice, rows: [{ quantity: 1, price: -100, taxPercent: 19 }] }, -120).paymentStatus, "Überbezahlt")
assert.equal(status({ ...invoice, rows: [] }, 0).paymentStatus, "Bezahlt")
const mixedTax = { ...invoice, rows: [{ quantity: 1, price: 0.03, taxPercent: 7 }, { quantity: 1, price: 0.02, taxPercent: 19 }] }
assert.equal(status(mixedTax, 0.05).paymentStatus, "Bezahlt", "separate tax buckets must match the outgoing-document UI")
assert.throws(() => status({ ...invoice, rows: [{ quantity: 1, price: "invalid" }] }, 0), /Positionsbetrag/)
assert.throws(() => status(invoice, NaN), /Betrag kann nicht berechnet/)
})
test("calendar validates explicit timezones, dates, editable fields and typed resources", () => {
assert.equal(eventDateTimeField.parse("2026-10-15T09:00:00+02:00", "startDate").toISOString(), "2026-10-15T07:00:00.000Z")
for (const value of ["2026-10-15", "2026-10-15T09:00:00", "2026-02-30T09:00:00Z", "2026-10-15T24:00:00Z", "2026-10-15T09:60:00Z", "bad", null]) {
assert.throws(() => eventDateTimeField.parse(value, "startDate"))
}
for (const args of [
{ id: 1 }, { id: -1, name: "Test" }, { id: 1, tenant: 2 }, { id: 1, updatedBy: userId },
{ id: 1, state: "Bezahlt" }, { id: 1, repeatInterval: "irgendwann" },
{ id: 1, profiles: [1] }, { id: 1, vehicles: ["1"] }, { id: 1, name: " " }, { id: 1, resources: [] },
]) assert.throws(() => prepareEventInput("update", args))
assert.throws(() => prepareEventInput("create", { name: "Test" }), /startDate/)
assert.deepEqual(prepareEventInput("update", { id: 1, endDate: null, profiles: [], vehicles: [1, 1] }), { id: 1, endDate: null, profiles: [], vehicles: [1] })
})
// An isolated database and local fixtures only; no production configuration is loaded.
test("calendar and outgoing status integration", { skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE }, async t => {
const container = "fedeo-mcp-calendar-" + randomUUID()
const password = randomUUID()
await execute("docker", ["run", "--rm", "-d", "--name", container, "-e", `POSTGRES_PASSWORD=${password}`, "-p", "127.0.0.1::5432", process.env.FEDEO_TEST_POSTGRES_IMAGE!])
let pool: Pool | undefined
t.after(async () => { await pool?.end(); await execute("docker", ["rm", "-f", container]) })
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
pool = new Pool({ host: "127.0.0.1", port: Number(stdout.trim().split(":").at(-1)), user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 })
for (let attempt = 0; ; attempt++) {
try { await pool.query("SELECT 1"); break } catch (error) {
if (attempt >= 80) throw error
await new Promise(resolve => setTimeout(resolve, 100))
}
}
const tables = [schema.events, schema.createddocuments, schema.statementallocations, schema.historyitems, schema.tenants,
schema.projects, schema.customers, schema.vendors, schema.authProfiles, schema.vehicles, schema.inventoryitems, schema.inventoryitemgroups]
for (const table of tables) {
const columns = Object.entries(getTableColumns(table)).map(([key, column]) => {
let suffix = ""
if (key === "id") suffix = column.getSQLType() === "uuid" ? " PRIMARY KEY DEFAULT gen_random_uuid()" : " GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"
else if (key === "archived" || key === "quick") suffix = " DEFAULT false"
else if (key === "active") suffix = " DEFAULT true"
else if (key === "createdAt") suffix = " DEFAULT now()"
else if (["profiles", "vehicles", "inventoryitems", "inventoryitemgroups", "resources", "rows", "usedAdvanceInvoices"].includes(key)) {
suffix = column.getSQLType().endsWith("[]") ? ` DEFAULT '{}'::${column.getSQLType()}` : " DEFAULT '[]'::jsonb"
}
else if (table === schema.events && key === "state") suffix = " DEFAULT 'Final'"
else if (table === schema.events && key === "repeatInterval") suffix = " DEFAULT 'Keine Wiederholung'"
else if (table === schema.events && key === "eventtype") suffix = " DEFAULT 'Umsetzung'"
return `"${column.name}" ${column.columnType === "PgEnumColumn" ? "text" : column.getSQLType()}${suffix}`
})
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(", ")})`)
}
const db = drizzle(pool) as any
const call = async (name: string, args: any = {}, tenantId = 1) => {
const context = { server: { db }, tenantId, userId, isAdmin: false,
permissions: ["organisation.events.read", "organisation.events.write", "accounting.outgoing_documents.read"] } as any
const tool = mcpToolMap.get(name)!
assert.ok(tool, name)
assertToolPermission(context, tool)
return await tool.handler(context, args) as any
}
const calendar = (name: string, args: any = {}, tenant = 1) => call(`organisation.events.${name}`, args, tenant)
const status = (name: string, args: any, tenant = 1) => call(`accounting.outgoing_documents.status.${name}`, args, tenant)
await db.insert(schema.tenants).values([{ id: 1, calendarConfig: { eventTypes: [{ label: "Büro", color: "blue" }] } }, { id: 2 }])
await db.insert(schema.projects).values([{ id: 1, tenant: 1, name: "Own" }, { id: 2, tenant: 2, name: "Foreign" }, { id: 3, tenant: 1, name: "Archived", archived: true }])
await db.insert(schema.vehicles).values([{ id: 1, tenant: 1 }, { id: 2, tenant: 2 }])
const ownProfile = randomUUID(), portalProfile = randomUUID(), group = randomUUID()
await db.insert(schema.authProfiles).values([{ id: ownProfile, tenant_id: 1 }, { id: portalProfile, tenant_id: 1, customer_for_portal: 5 }])
await db.insert(schema.inventoryitemgroups).values({ id: group, tenant: 1 })
await t.test("create, update and archive preserve metadata, history and tenant isolation", async () => {
const options = await call("organisation.calendar.options")
assert.equal(options.calendarConfig.eventTypes[0].label, "Büro")
const { event } = await calendar("create", { name: "Besprechung", startDate: "2026-10-15T09:00:00+02:00", endDate: "2026-10-15T10:00:00+02:00",
project: 1, profiles: [ownProfile], vehicles: [1], inventoryitemgroups: [group], repeatInterval: "Wöchentlich" })
assert.equal(event.state, "Final")
assert.equal(event.updatedBy, userId)
assert.equal(event.tenant, 1)
assert.equal(event.startDate.toISOString(), "2026-10-15T07:00:00.000Z")
await assert.rejects(calendar("get", { id: event.id }, 2), /nicht gefunden/)
await assert.rejects(calendar("update", { id: event.id, name: "wrong" }, 2), /nicht gefunden/)
await assert.rejects(calendar("archive", { id: event.id }, 2), /nicht gefunden/)
for (const patch of [{ project: 2 }, { project: 3 }, { profiles: [portalProfile] }, { vehicles: [2] }, { inventoryitemgroups: [randomUUID()] }]) {
await assert.rejects(calendar("update", { id: event.id, ...patch }), /nicht verfügbar/)
}
await assert.rejects(calendar("update", { id: event.id, startDate: "2026-10-15T11:00:00+02:00" }), /endDate/)
const changed = await calendar("update", { id: event.id, name: "Planung", endDate: null, profiles: [], project: null, state: "Entwurf" })
assert.equal(changed.event.endDate, null)
assert.deepEqual(changed.event.profiles, [])
assert.equal(changed.event.repeatInterval, "Wöchentlich")
assert.equal(changed.event.project, null)
const history = await db.select().from(schema.historyitems).where(eq(schema.historyitems.event, event.id))
assert.deepEqual(history.map(row => row.action), ["created", "updated"])
assert.ok(history.every(row => row.tenant === 1 && row.createdBy === userId))
await pool!.query("ALTER TABLE historyitems ADD CONSTRAINT reject_new_history CHECK (false) NOT VALID")
await assert.rejects(calendar("update", { id: event.id, name: "Must roll back" }))
assert.equal((await calendar("get", { id: event.id })).event.name, "Planung")
await pool!.query("ALTER TABLE historyitems DROP CONSTRAINT reject_new_history")
assert.deepEqual(asToolResult(await calendar("archive", { id: event.id })).structuredContent, { id: event.id, success: true, action: "archived" })
await assert.rejects(calendar("get", { id: event.id }), /archiviert/)
assert.equal((await calendar("list", {})).rows.length, 0)
})
await t.test("calendar range and resource filters handle overlaps and older recurring series", async () => {
const create = async (name: string, startDate: string, endDate: string | null, extra: any = {}, tenant = 1) =>
(await calendar("create", { name, startDate, endDate, ...extra }, tenant)).event
await create("Before", "2026-10-10T09:00:00Z", "2026-10-10T10:00:00Z")
await create("Ends at boundary", "2026-10-10T09:00:00Z", "2026-10-15T00:00:00Z")
const spanning = await create("Overlapping", "2026-10-14T23:00:00Z", "2026-10-15T01:00:00Z", { profiles: [ownProfile], vehicles: [1], state: "Entwurf", quick: true })
const inside = await create("Inside", "2026-10-15T10:00:00Z", null)
await create("End excluded", "2026-10-16T00:00:00Z", null)
const recurring = await create("Older series", "2020-10-01T09:00:00Z", "2020-10-01T10:00:00Z", { repeatInterval: "Wöchentlich" })
await create("Foreign", "2026-10-15T10:00:00Z", null, {}, 2)
const range = { from: "2026-10-15T00:00:00Z", to: "2026-10-16T00:00:00Z" }
const listed = await calendar("list", range)
assert.deepEqual(listed.rows.map(row => row.id), [inside.id, spanning.id, recurring.id])
assert.deepEqual((await calendar("list", { ...range, includeRecurring: false })).rows.map(row => row.id), [inside.id, spanning.id])
assert.deepEqual((await calendar("list", { ...range, profile: ownProfile, vehicle: 1, state: "Entwurf", quick: true })).rows.map(row => row.id), [spanning.id])
await assert.rejects(calendar("list", { from: range.to, to: range.from }), /from/)
})
await t.test("status ignores foreign/archived payments and draft cancellations and applies advances", async () => {
await db.insert(schema.createddocuments).values([
{ ...invoice, id: 100, tenant: 1, documentNumber: "RE-100", usedAdvanceInvoices: [103] },
{ ...invoice, id: 101, tenant: 2 }, { ...invoice, id: 102, tenant: 1, archived: true },
{ ...invoice, id: 103, tenant: 1, type: "advanceInvoices", rows: [{ price: 20, taxPercent: 19, advanceInvoiceData: {} }] },
{ ...invoice, id: 104, tenant: 1, type: "cancellationInvoices", state: "Entwurf", createddocument: 100 },
{ ...invoice, id: 105, tenant: 2, type: "cancellationInvoices", createddocument: 100 },
{ ...invoice, id: 106, tenant: 1, type: "cancellationInvoices", createddocument: 100, archived: true },
])
await db.insert(schema.statementallocations).values([
{ tenant: 1, createddocument: 100, amount: 95.2 }, { tenant: 1, createddocument: 100, amount: 500, archived: true },
{ tenant: 2, createddocument: 100, amount: 700 },
])
const result = (await status("get", { id: 100 })).status
assert.equal(result.paymentStatus, "Bezahlt")
assert.equal(result.amount, 95.2)
assert.equal(result.paidAmount, 95.2)
assert.equal(result.openAmount, 0)
assert.equal(result.cancelled, false)
await assert.rejects(status("get", { id: 101 }), /nicht gefunden/)
await assert.rejects(status("get", { id: 102 }), /archiviert/)
await db.insert(schema.createddocuments).values({ ...invoice, id: 107, tenant: 1, documentNumber: "ST-107", type: "cancellationInvoices", createddocument: 100 })
const cancelled = (await status("get", { id: 100 })).status
assert.equal(cancelled.paymentStatus, null)
assert.deepEqual(cancelled.cancellationDocuments, [{ id: 107, documentNumber: "ST-107" }])
assert.deepEqual((await status("list", { cancelled: true })).rows.map(row => row.id), [100])
})
await t.test("computed status filters precede pagination across several database batches", async () => {
const documents = Array.from({ length: 410 }, (_, index) => ({ ...invoice, id: 1000 + index, tenant: 1, project: 999,
createdAt: new Date("2026-10-01T00:00:00Z") }))
await db.insert(schema.createddocuments).values(documents)
await db.insert(schema.statementallocations).values(documents.filter((_, i) => i % 2 === 0).map(document => ({ tenant: 1, createddocument: document.id, amount: 119 })))
const ids: number[] = []
for (const offset of [0, 100, 200]) {
const result = await status("list", { state: "Gebucht", paymentStatus: "Bezahlt", cancelled: false, project: 999, limit: 100, offset })
assert.equal(result.rows.length, offset < 200 ? 100 : 5)
assert.equal(result.pagination.nextOffset, offset < 200 ? offset + 100 : null)
ids.push(...result.rows.map(row => row.id))
}
assert.deepEqual(ids, documents.filter((_, i) => i % 2 === 0).map(document => document.id))
assert.equal((await status("list", { project: 999, paymentStatus: "Teilbezahlt" })).rows.length, 0)
assert.equal((await status("list", { project: 999, offset: 400, limit: 100 })).rows.length, 10)
await assert.rejects(status("list", { paymentStatus: "unknown" }), /Zahlungsstatus/)
await assert.rejects(status("get", { id: 100, state: "Entwurf" }), /unbekanntes Feld/)
})
})

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