Compare commits
48 Commits
23d49f7186
...
v3.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a8c6c2625c | |||
| 5463106938 | |||
| 070be08207 | |||
| f4fdf6fe30 | |||
| 899e5bb339 | |||
| a2a1151ccb | |||
| 15e480ee9a | |||
| c2c6ca8fe9 | |||
| ac00d2187e | |||
| 29a9ec2242 | |||
| 59538f46b7 | |||
| 677ae0f4fb | |||
| 4c67ddd908 | |||
| efa6ea90a2 | |||
| fdcb9bfc1d | |||
| daaed01d02 | |||
| 7e6c2fbac5 | |||
| 0887863981 | |||
| 6ff9971ff9 | |||
| e7d6c482ad | |||
| 1885bfc521 | |||
| 09a002d600 | |||
| ed305f656c | |||
| d7382e7319 | |||
| 64299843e2 | |||
| b01cf2c207 | |||
| 30e932a874 | |||
| 0a74e07288 | |||
| 4c3de8c942 | |||
| 49d44d9701 | |||
| 96a7acff33 | |||
| 65588686c7 | |||
| fcf9cc3fc9 | |||
| 20091143d2 | |||
| f708d0a2b2 | |||
| 7e9f4409f6 | |||
| 2e63af1aa2 | |||
| 5ebd2d508c | |||
| 9128fbb165 | |||
| 8473221c13 | |||
| e7554fa2cc | |||
| 7c1fabf58a | |||
| 1203b6cbd1 | |||
| 525f2906fb | |||
| b105382abf | |||
| b1cdec7d17 | |||
| f1d512b2e5 | |||
| db21b43120 |
12
.env.example
12
.env.example
@@ -51,11 +51,13 @@ SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
|
|||||||
|
|
||||||
M2M_API_KEY=change-this-m2m-key
|
M2M_API_KEY=change-this-m2m-key
|
||||||
API_BASE_URL=https://app.example.com/backend
|
API_BASE_URL=https://app.example.com/backend
|
||||||
# Collaboration: public browser URL and canonical HTTPS API address (including reverse-proxy prefix).
|
# Collaboration uses API_BASE_URL for federation and its origin for guest links.
|
||||||
FEDEO_PUBLIC_URL=https://app.example.com
|
# Optional overrides only, e.g. when using a separate federation gateway.
|
||||||
FEDEO_FEDERATION_URL=https://app.example.com/backend
|
FEDEO_PUBLIC_URL=
|
||||||
# Requires mutual tenant partner approval under Zusammenarbeit.
|
FEDEO_FEDERATION_URL=
|
||||||
FEDEO_FEDERATION_ENABLED=false
|
# 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
|
||||||
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
||||||
GOCARDLESS_SECRET_ID=replace-this
|
GOCARDLESS_SECRET_ID=replace-this
|
||||||
GOCARDLESS_SECRET_KEY=replace-this
|
GOCARDLESS_SECRET_KEY=replace-this
|
||||||
|
|||||||
@@ -2,37 +2,18 @@
|
|||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
|
||||||
title: '[BUG] '
|
title: '[BUG] '
|
||||||
labels: bug
|
labels: Problem
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Beschreibung**
|
**Beschreibung**
|
||||||
Eine klare und prägnante Beschreibung des Fehlers.
|
|
||||||
|
|
||||||
**Reproduktion**
|
**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**
|
**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.**
|
**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.
|
|
||||||
@@ -2,19 +2,16 @@
|
|||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
about: Schlage eine Idee für dieses Projekt vor.
|
about: Schlage eine Idee für dieses Projekt vor.
|
||||||
title: '[FEATURE] '
|
title: '[FEATURE] '
|
||||||
labels: enhancement
|
labels: Funktionswunsch
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
|
||||||
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
|
|
||||||
|
|
||||||
**Lösungsvorschlag**
|
**Lösungsvorschlag**
|
||||||
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
|
|
||||||
|
|
||||||
**Alternativen**
|
**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.
|
|
||||||
@@ -87,6 +87,8 @@ Minimal wichtige Werte fur den ersten Start:
|
|||||||
- `DATABASE_URL=postgres://fedeo:<starkes-passwort>@db:5432/fedeo`
|
- `DATABASE_URL=postgres://fedeo:<starkes-passwort>@db:5432/fedeo`
|
||||||
- `API_BASE_URL=https://app.example.com/backend`
|
- `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:
|
Wenn du MinIO verwendest, setze zusatzlich:
|
||||||
|
|
||||||
- `S3_ENDPOINT=http://minio:9000`
|
- `S3_ENDPOINT=http://minio:9000`
|
||||||
|
|||||||
13
backend/db/migrations/0077_scanner_inbox_preparation.sql
Normal file
13
backend/db/migrations/0077_scanner_inbox_preparation.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE "scanner_inbox_documents"
|
||||||
|
ADD COLUMN "suggestion_status" text NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN "suggestion" jsonb,
|
||||||
|
ADD COLUMN "suggestion_message" text,
|
||||||
|
ADD COLUMN "suggestion_claim" uuid,
|
||||||
|
ADD COLUMN "suggestion_updated_at" timestamptz NOT NULL DEFAULT now();
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "scanner_inbox_documents" ADD CONSTRAINT "scanner_inbox_suggestion_status_check"
|
||||||
|
CHECK ("suggestion_status" IN ('pending', 'processing', 'ready', 'failed'));
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "scanner_inbox_suggestion_queue_idx"
|
||||||
|
ON "scanner_inbox_documents" ("suggestion_status", "suggestion_updated_at", "received_at")
|
||||||
|
WHERE "filed_at" IS NULL;
|
||||||
8
backend/db/migrations/0078_chat_visibility.sql
Normal file
8
backend/db/migrations/0078_chat_visibility.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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")
|
||||||
|
);
|
||||||
19
backend/db/migrations/0079_notification_matrix.sql
Normal file
19
backend/db/migrations/0079_notification_matrix.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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');
|
||||||
93
backend/db/migrations/0080_staff_time_spans.sql
Normal file
93
backend/db/migrations/0080_staff_time_spans.sql
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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();
|
||||||
22
backend/db/migrations/0081_chat_media_reactions.sql
Normal file
22
backend/db/migrations/0081_chat_media_reactions.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
@@ -519,6 +519,41 @@
|
|||||||
"when": 1790726400001,
|
"when": 1790726400001,
|
||||||
"tag": "0076_collaboration_federation",
|
"tag": "0076_collaboration_federation",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 74,
|
||||||
|
"version": "7",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
19
backend/db/schema/chat_reactions.ts
Normal file
19
backend/db/schema/chat_reactions.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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`) }))
|
||||||
@@ -50,10 +50,10 @@ export const collaborationMembers = pgTable("collaboration_members", {
|
|||||||
export const collaborationMessages = pgTable("collaboration_messages", {
|
export const collaborationMessages = pgTable("collaboration_messages", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
|
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),
|
seq: bigint("seq", { mode: "number" }).notNull(), principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id),
|
||||||
clientId: uuid("client_id").notNull(), body: text("body").notNull(), createdAt: time("created_at").notNull().defaultNow(),
|
clientId: uuid("client_id").notNull(), body: text("body").notNull(), image: jsonb("image"), createdAt: time("created_at").notNull().defaultNow(),
|
||||||
}, t => ({ seq: uniqueIndex("collaboration_messages_room_id_seq_key").on(t.roomId, t.seq),
|
}, 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),
|
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}) BETWEEN 1 AND 10000`) }))
|
body: check("collaboration_messages_body_check", sql`length(${t.body}) <= 10000 AND (length(${t.body}) > 0 OR ${t.image} IS NOT NULL)`) }))
|
||||||
export const collaborationInvitations = pgTable("collaboration_invitations", {
|
export const collaborationInvitations = pgTable("collaboration_invitations", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
|
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(),
|
targetPrincipalId: uuid("target_principal_id").notNull().references(() => collaborationPrincipals.id), tokenHash: text("token_hash").notNull().unique(),
|
||||||
|
|||||||
10
backend/db/schema/communication_hidden_rooms.ts
Normal file
10
backend/db/schema/communication_hidden_rooms.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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] }) }))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
|
import { bigint, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
import { communicationRooms } from "./communication_rooms"
|
import { communicationRooms } from "./communication_rooms"
|
||||||
@@ -20,6 +20,7 @@ export const communicationMessages = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => authUsers.id),
|
.references(() => authUsers.id),
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
|
image: jsonb("image"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export * from "./communication_rooms"
|
|||||||
export * from "./communication_room_members"
|
export * from "./communication_room_members"
|
||||||
export * from "./communication_messages"
|
export * from "./communication_messages"
|
||||||
export * from "./communication_room_reads"
|
export * from "./communication_room_reads"
|
||||||
|
export * from "./communication_hidden_rooms"
|
||||||
export * from "./contacts"
|
export * from "./contacts"
|
||||||
export * from "./contracts"
|
export * from "./contracts"
|
||||||
export * from "./contracttypes"
|
export * from "./contracttypes"
|
||||||
@@ -104,3 +105,6 @@ export * from "./scanner_inbox"
|
|||||||
export * from "./collaboration";
|
export * from "./collaboration";
|
||||||
|
|
||||||
export * from "./collaboration_federation"
|
export * from "./collaboration_federation"
|
||||||
|
export * from './staff_time_spans'
|
||||||
|
|
||||||
|
export * from "./chat_reactions"
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export const projects = pgTable("projects", {
|
|||||||
|
|
||||||
archived: boolean("archived").notNull().default(false),
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
|
chatEnabled: boolean("chat_enabled").notNull().default(true),
|
||||||
|
|
||||||
customerRef: text("customerRef"),
|
customerRef: text("customerRef"),
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { bigint, boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"
|
import { bigint, boolean, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
import { files } from "./files"
|
import { files } from "./files"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
|
||||||
export const scannerInboxConnections = pgTable("scanner_inbox_connections", {
|
export const scannerInboxConnections = pgTable("scanner_inbox_connections", {
|
||||||
tenantId: bigint("tenant_id", { mode: "number" }).primaryKey().references(() => tenants.id, { onDelete: "cascade" }),
|
tenantId: bigint("tenant_id", { mode: "number" }).primaryKey().references(() => tenants.id, { onDelete: "cascade" }),
|
||||||
@@ -18,7 +19,13 @@ export const scannerInboxDocuments = pgTable("scanner_inbox_documents", {
|
|||||||
checksum: text("checksum").notNull(),
|
checksum: text("checksum").notNull(),
|
||||||
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
filedAt: timestamp("filed_at", { withTimezone: true }),
|
filedAt: timestamp("filed_at", { withTimezone: true }),
|
||||||
|
suggestionStatus: text("suggestion_status").notNull().default("pending"),
|
||||||
|
suggestion: jsonb("suggestion"),
|
||||||
|
suggestionMessage: text("suggestion_message"),
|
||||||
|
suggestionClaim: uuid("suggestion_claim"),
|
||||||
|
suggestionUpdatedAt: timestamp("suggestion_updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
}, table => ({
|
}, table => ({
|
||||||
filenameKey: uniqueIndex("scanner_inbox_documents_filename_key").on(table.tenantId, table.remoteFilename),
|
filenameKey: uniqueIndex("scanner_inbox_documents_filename_key").on(table.tenantId, table.remoteFilename),
|
||||||
receivedIdx: index("scanner_inbox_documents_received_idx").on(table.tenantId, table.receivedAt),
|
receivedIdx: index("scanner_inbox_documents_received_idx").on(table.tenantId, table.receivedAt),
|
||||||
|
suggestionQueueIdx: index("scanner_inbox_suggestion_queue_idx").on(table.suggestionStatus, table.suggestionUpdatedAt, table.receivedAt).where(sql`${table.filedAt} IS NULL`),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const serialExecutions = pgTable("serial_executions", {
|
|||||||
tenant: bigint("tenant", { mode: "number" })
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id), executionDate: timestamp("execution_date").notNull(),
|
.references(() => tenants.id), executionDate: timestamp("execution_date").notNull(),
|
||||||
status: text("status").default("draft"), // 'draft', 'completed'
|
status: text("status").default("draft"), // generating, draft, processing, cancelling, cancelled, completed, error
|
||||||
createdBy: text("created_by"), // oder UUID, je nach Auth-System
|
createdBy: text("created_by"), // oder UUID, je nach Auth-System
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
summary: text("summary"), // z.B. "25 Rechnungen erstellt"
|
summary: text("summary"), // z.B. "25 Rechnungen erstellt"
|
||||||
|
|||||||
48
backend/db/schema/staff_time_spans.ts
Normal file
48
backend/db/schema/staff_time_spans.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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] })]);
|
||||||
@@ -26,6 +26,7 @@ import helpdeskRoutes from "./routes/helpdesk";
|
|||||||
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
|
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
|
||||||
import notificationsRoutes from "./routes/notifications";
|
import notificationsRoutes from "./routes/notifications";
|
||||||
import staffTimeRoutes from "./routes/staff/time";
|
import staffTimeRoutes from "./routes/staff/time";
|
||||||
|
import timeV2Routes from "./routes/staff/time-v2";
|
||||||
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
||||||
import userRoutes from "./routes/auth/user";
|
import userRoutes from "./routes/auth/user";
|
||||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||||
@@ -69,6 +70,7 @@ import {initS3} from "./utils/s3";
|
|||||||
import { runBootstrap } from "./modules/bootstrap.service";
|
import { runBootstrap } from "./modules/bootstrap.service";
|
||||||
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
|
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
|
||||||
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
|
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
|
||||||
|
import { startScannerInboxWorker } from "./modules/scanner-inbox.worker";
|
||||||
|
|
||||||
|
|
||||||
//Services
|
//Services
|
||||||
@@ -97,6 +99,7 @@ async function main() {
|
|||||||
await runBootstrap(app);
|
await runBootstrap(app);
|
||||||
startCentralServicesHeartbeat(app);
|
startCentralServicesHeartbeat(app);
|
||||||
startDocumentImportWorker(app);
|
startDocumentImportWorker(app);
|
||||||
|
startScannerInboxWorker(app);
|
||||||
|
|
||||||
app.addHook('preHandler', (req, reply, done) => {
|
app.addHook('preHandler', (req, reply, done) => {
|
||||||
console.log(req.method)
|
console.log(req.method)
|
||||||
@@ -166,6 +169,7 @@ async function main() {
|
|||||||
await subApp.register(helpdeskRoutes);
|
await subApp.register(helpdeskRoutes);
|
||||||
await subApp.register(notificationsRoutes);
|
await subApp.register(notificationsRoutes);
|
||||||
await subApp.register(staffTimeRoutes);
|
await subApp.register(staffTimeRoutes);
|
||||||
|
await subApp.register(timeV2Routes);
|
||||||
await subApp.register(staffTimeConnectRoutes);
|
await subApp.register(staffTimeConnectRoutes);
|
||||||
await subApp.register(userRoutes);
|
await subApp.register(userRoutes);
|
||||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export async function createMcpContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertToolPermission(context: McpContext, tool: McpTool) {
|
export function assertToolPermission(context: McpContext, tool: Pick<McpTool, "name" | "requiredPermissions">) {
|
||||||
if (context.isAdmin) return
|
if (context.isAdmin) return
|
||||||
|
|
||||||
const allowed = tool.requiredPermissions.every((permission) =>
|
const allowed = tool.requiredPermissions.every((permission) =>
|
||||||
@@ -85,4 +85,3 @@ export function assertToolPermission(context: McpContext, tool: McpTool) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
backend/src/mcp/documentFiles.ts
Normal file
44
backend/src/mcp/documentFiles.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import { accountingTools } from "./tools/accounting"
|
|||||||
import { masterdataTools } from "./tools/masterdata"
|
import { masterdataTools } from "./tools/masterdata"
|
||||||
import { organisationTools } from "./tools/organisation"
|
import { organisationTools } from "./tools/organisation"
|
||||||
import { wikiTools } from "./tools/wiki"
|
import { wikiTools } from "./tools/wiki"
|
||||||
|
import { documentTools } from "./tools/documents"
|
||||||
|
|
||||||
export const mcpTools = [
|
export const mcpTools = [
|
||||||
...accountingTools,
|
...accountingTools,
|
||||||
...masterdataTools,
|
...masterdataTools,
|
||||||
...organisationTools,
|
...organisationTools,
|
||||||
...wikiTools,
|
...wikiTools,
|
||||||
|
...documentTools,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
} from "../statementAllocations"
|
} from "../statementAllocations"
|
||||||
import { McpTool } from "../types"
|
import { McpTool } from "../types"
|
||||||
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
|
|
||||||
@@ -423,6 +424,7 @@ const assertStatementAllocationReferences = async (context: any, payload: Record
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const accountingTools: McpTool[] = [
|
export const accountingTools: McpTool[] = [
|
||||||
|
...outgoingStatusTools,
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.tax_types.list",
|
name: "accounting.outgoing_documents.tax_types.list",
|
||||||
title: "Steuertypen für Ausgangsbelege auflisten",
|
title: "Steuertypen für Ausgangsbelege auflisten",
|
||||||
@@ -802,7 +804,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
status: { type: "string", enum: ["draft", "completed", "error"] },
|
status: { type: "string", enum: ["generating", "draft", "processing", "cancelling", "cancelled", "completed", "error"] },
|
||||||
...paginationProperties,
|
...paginationProperties,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
166
backend/src/mcp/tools/calendar.ts
Normal file
166
backend/src/mcp/tools/calendar.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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),
|
||||||
|
})),
|
||||||
|
]
|
||||||
148
backend/src/mcp/tools/documents.ts
Normal file
148
backend/src/mcp/tools/documents.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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. 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()
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
import { customers, plants, projects, tasks } from "../../../db/schema"
|
||||||
import { insertHistoryItem } from "../../utils/history"
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
import { activateProjectPhase } from "../projectPhases"
|
import { activateProjectPhase } from "../projectPhases"
|
||||||
import { McpTool } from "../types"
|
import { McpTool } from "../types"
|
||||||
|
import { calendarTools } from "./calendar"
|
||||||
|
|
||||||
|
|
||||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
@@ -17,6 +18,7 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const organisationTools: McpTool[] = [
|
export const organisationTools: McpTool[] = [
|
||||||
|
...calendarTools,
|
||||||
{
|
{
|
||||||
name: "organisation.customers.search",
|
name: "organisation.customers.search",
|
||||||
title: "Kunden suchen",
|
title: "Kunden suchen",
|
||||||
@@ -238,51 +240,6 @@ export const organisationTools: McpTool[] = [
|
|||||||
return paginatedResult(rows, args)
|
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",
|
name: "organisation.tasks.list",
|
||||||
title: "Aufgaben auflisten",
|
title: "Aufgaben auflisten",
|
||||||
|
|||||||
130
backend/src/mcp/tools/outgoingStatus.ts
Normal file
130
backend/src/mcp/tools/outgoingStatus.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -57,10 +57,13 @@ const adminPermissions = [
|
|||||||
"organisation.projects.write",
|
"organisation.projects.write",
|
||||||
"organisation.plants.read",
|
"organisation.plants.read",
|
||||||
"organisation.events.read",
|
"organisation.events.read",
|
||||||
|
"organisation.events.write",
|
||||||
"organisation.tasks.read",
|
"organisation.tasks.read",
|
||||||
"organisation.tasks.write",
|
"organisation.tasks.write",
|
||||||
"wiki.read",
|
"wiki.read",
|
||||||
"wiki.write",
|
"wiki.write",
|
||||||
|
"documents.read",
|
||||||
|
"documents.write",
|
||||||
]
|
]
|
||||||
|
|
||||||
const defaultTaxTypes = [
|
const defaultTaxTypes = [
|
||||||
|
|||||||
90
backend/src/modules/chat-content.ts
Normal file
90
backend/src/modules/chat-content.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
30
backend/src/modules/collaboration/config.ts
Normal file
30
backend/src/modules/collaboration/config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { chatContent, imageMetadata } from "../chat-content"
|
||||||
import { generateKeyPairSync, randomUUID } from "node:crypto"
|
import { generateKeyPairSync, randomUUID } from "node:crypto"
|
||||||
import { sql } from "drizzle-orm"
|
import { sql } from "drizzle-orm"
|
||||||
import type { FastifyInstance } from "fastify"
|
import type { FastifyInstance } from "fastify"
|
||||||
@@ -7,6 +8,7 @@ import { FederationKey, Headers, instanceUrl, safeTransport, signedHeaders, Tran
|
|||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
apiUrl: string; enabled: boolean;
|
apiUrl: string; enabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
seal: (value: string) => any; unseal: (value: any) => string;
|
seal: (value: string) => any; unseal: (value: any) => string;
|
||||||
transport?: Transport;
|
transport?: Transport;
|
||||||
}
|
}
|
||||||
@@ -22,7 +24,7 @@ export class FederationService {
|
|||||||
this.transport = options.transport || safeTransport
|
this.transport = options.transport || safeTransport
|
||||||
}
|
}
|
||||||
enabled() {
|
enabled() {
|
||||||
if (!this.options.enabled) fail("Der Betreiber muss die Föderation zuerst aktivieren", 503)
|
if (!this.options.enabled) fail(this.options.disabledReason || "Der Betreiber muss die Föderation zuerst aktivieren", 503)
|
||||||
instanceUrl(this.origin)
|
instanceUrl(this.origin)
|
||||||
}
|
}
|
||||||
async keys() {
|
async keys() {
|
||||||
@@ -71,7 +73,7 @@ export class FederationService {
|
|||||||
VALUES (${ctx.tenantId},${origin},${instanceId},${keyId},${publicKey}) ON CONFLICT(tenant_id,origin) DO UPDATE SET active=true,updated_at=now()`)
|
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, origin: this.origin, partners: (await rows(this.service.db,
|
return { enabled: this.options.enabled, disabledReason: this.options.disabledReason, 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) })) }
|
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) {
|
async call(tenantId: number, origin: string, action: string, payload: any) {
|
||||||
@@ -121,7 +123,9 @@ export class FederationService {
|
|||||||
ON CONFLICT(dedup_key) DO UPDATE SET dedup_key=excluded.dedup_key RETURNING *`)
|
ON CONFLICT(dedup_key) DO UPDATE SET dedup_key=excluded.dedup_key RETURNING *`)
|
||||||
if (JSON.stringify(job.payload) !== JSON.stringify(entry.payload)) {
|
if (JSON.stringify(job.payload) !== JSON.stringify(entry.payload)) {
|
||||||
// JSONB key order differs: compare the actual command values instead of its serialization.
|
// 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) 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
|
||||||
|
|| (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)
|
||||||
}
|
}
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
@@ -222,6 +226,9 @@ export class FederationService {
|
|||||||
if (action === "room.get") return this.service.room(principal, room.id)
|
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.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 === "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 === "members.get") return { members: await this.service.members(principal, room.id) }
|
||||||
if (action === "read") return this.service.markRead(principal, room.id, request.input?.seq)
|
if (action === "read") return this.service.markRead(principal, room.id, request.input?.seq)
|
||||||
fail("Unbekannte Föderationsaktion")
|
fail("Unbekannte Föderationsaktion")
|
||||||
@@ -299,7 +306,7 @@ export class FederationService {
|
|||||||
}
|
}
|
||||||
async cache(binding: any, messages: any[], snapshot?: any) {
|
async cache(binding: any, messages: any[], snapshot?: any) {
|
||||||
if (!Array.isArray(messages) || messages.length > 50) fail("Ungültige Nachrichtenantwort", 502)
|
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),
|
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),
|
||||||
senderName: text(m.senderName, 400), senderKind: ['user','guest','remote'].includes(m.senderKind) ? m.senderKind : 'remote',
|
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() }))
|
own: m.own === true, createdAt: new Date(m.createdAt).toISOString() }))
|
||||||
await this.service.db.transaction(async db => {
|
await this.service.db.transaction(async db => {
|
||||||
@@ -325,8 +332,9 @@ export class FederationService {
|
|||||||
await this.allowed(ctx.tenantId, binding.origin)
|
await this.allowed(ctx.tenantId, binding.origin)
|
||||||
if (action === "send") {
|
if (action === "send") {
|
||||||
if (!binding.snapshot?.canSend) fail("Unterraum ist nicht zum Schreiben freigegeben", 403)
|
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,
|
const command = { actorId: ctx.principal.id, roomId: binding.room_id,
|
||||||
input: { clientId: uuid(input.clientId), text: text(input.text, 10000), revision: Number(binding.revision) } }
|
input: { clientId: uuid(input.clientId), text: content.body, image: content.image, revision: Number(binding.revision) } }
|
||||||
const job = await this.enqueue(this.service.db, { key: `message:${binding.id}:${command.input.clientId}`, ctx, origin: binding.origin,
|
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 })
|
action: 'messages.send', payload: command, bindingId: binding.id })
|
||||||
await this.deliver(job)
|
await this.deliver(job)
|
||||||
@@ -335,6 +343,20 @@ export class FederationService {
|
|||||||
if (currentJob.status === 'failed') fail(currentJob.last_error || "Nachricht konnte nicht gesendet werden", 409)
|
if (currentJob.status === 'failed') fail(currentJob.last_error || "Nachricht konnte nicht gesendet werden", 409)
|
||||||
return { found: true, result: message?.payload || this.pendingMessage(currentJob) }
|
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)
|
const detail = await this.sync(ctx, binding)
|
||||||
binding = await this.binding(ctx, id)
|
binding = await this.binding(ctx, id)
|
||||||
if (action === "room") return { found: true, result: detail }
|
if (action === "room") return { found: true, result: detail }
|
||||||
@@ -367,7 +389,7 @@ export class FederationService {
|
|||||||
return { found: true, result: { messages, hasMore: page.length === 50, offline: detail.offline } }
|
return { found: true, result: { messages, hasMore: page.length === 50, offline: detail.offline } }
|
||||||
}
|
}
|
||||||
pendingMessage(job: any) {
|
pendingMessage(job: any) {
|
||||||
return { id: job.payload.input.clientId, clientId: job.payload.input.clientId, seq: null, body: job.payload.input.text,
|
return { id: job.payload.input.clientId, clientId: job.payload.input.clientId, seq: null, body: job.payload.input.text, image: imageMetadata(job.payload.input.image),
|
||||||
createdAt: job.created_at, own: true, senderName: 'Du', senderKind: 'user', pending: job.status === 'pending', failed: job.status === 'failed' }
|
createdAt: job.created_at, own: true, senderName: 'Du', senderKind: 'user', pending: job.status === 'pending', failed: job.status === 'failed' }
|
||||||
}
|
}
|
||||||
async deliver(job: any) {
|
async deliver(job: any) {
|
||||||
@@ -432,12 +454,12 @@ export class FederationService {
|
|||||||
|
|
||||||
export async function federationRoutes(server: FastifyInstance, { federation }: { federation: FederationService }) {
|
export async function federationRoutes(server: FastifyInstance, { federation }: { federation: FederationService }) {
|
||||||
server.removeContentTypeParser('application/json')
|
server.removeContentTypeParser('application/json')
|
||||||
server.addContentTypeParser('application/json', { parseAs: 'string', bodyLimit: 128 * 1024 }, (req: any, body: string, done) => {
|
server.addContentTypeParser('application/json', { parseAs: 'string', bodyLimit: 1024 * 1024 }, (req: any, body: string, done) => {
|
||||||
req.federationBody = body
|
req.federationBody = body
|
||||||
try { done(null, JSON.parse(body)) } catch { done(Object.assign(new Error('Ungültiges JSON'), { statusCode: 400 })) }
|
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.get('/.well-known/fedeo-federation', async () => federation.discovery())
|
||||||
server.post('/federation/v1/rpc', { bodyLimit: 128 * 1024 }, async (req: any, reply) => {
|
server.post('/federation/v1/rpc', { bodyLimit: 1024 * 1024 }, async (req: any, reply) => {
|
||||||
reply.header('Cache-Control','no-store')
|
reply.header('Cache-Control','no-store')
|
||||||
// Duplicate signature-bearing fields are rejected rather than silently combined.
|
// 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())
|
const names = req.raw.rawHeaders.filter((_: string, index: number) => index % 2 === 0).map((name: string) => name.toLowerCase())
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ import { sendMail } from "../../utils/mailer"
|
|||||||
import { CollaborationService } from "./service"
|
import { CollaborationService } from "./service"
|
||||||
import { FederationService } from "./federation"
|
import { FederationService } from "./federation"
|
||||||
import { encrypt, decrypt } from "../../utils/crypt"
|
import { encrypt, decrypt } from "../../utils/crypt"
|
||||||
|
import { collaborationConfig } from "./config"
|
||||||
|
|
||||||
export function collaborationRuntime(server: FastifyInstance) {
|
export function collaborationRuntime(server: FastifyInstance) {
|
||||||
const apiUrl = String(process.env.FEDEO_FEDERATION_URL || secrets.API_BASE_URL || "").replace(/\/$/, "")
|
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
|
||||||
const publicUrl = String(process.env.FEDEO_PUBLIC_URL || (apiUrl ? new URL(apiUrl).origin : "")).replace(/\/$/, "")
|
...process.env, API_BASE_URL: secrets.API_BASE_URL, ENCRYPTION_KEY: secrets.ENCRYPTION_KEY,
|
||||||
|
})
|
||||||
const service = new CollaborationService(server.db, {
|
const service = new CollaborationService(server.db, {
|
||||||
publicUrl,
|
publicUrl,
|
||||||
guestPepper: () => secrets.ENCRYPTION_KEY || "",
|
guestPepper: () => secrets.ENCRYPTION_KEY || "",
|
||||||
sendMail,
|
sendMail,
|
||||||
})
|
})
|
||||||
const federation = new FederationService(service, { apiUrl, enabled: process.env.FEDEO_FEDERATION_ENABLED === "true", seal: encrypt, unseal: decrypt })
|
const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt })
|
||||||
service.options.changed = (roomId, db) => federation.changed(roomId, db)
|
service.options.changed = (roomId, db) => federation.changed(roomId, db)
|
||||||
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
|
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
|
||||||
let timer: ReturnType<typeof setInterval>
|
let timer: ReturnType<typeof setInterval>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { sql } from "drizzle-orm"
|
|||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
|
||||||
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
|
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
|
||||||
|
|
||||||
|
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content"
|
||||||
|
|
||||||
type Db = Pick<NodePgDatabase<any>, "execute" | "transaction">
|
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 const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows
|
||||||
export type CollaborationOptions = {
|
export type CollaborationOptions = {
|
||||||
@@ -172,7 +174,7 @@ export class CollaborationService {
|
|||||||
const room = await this.roomRow(this.db, roomId)
|
const room = await this.roomRow(this.db, roomId)
|
||||||
const member = await this.membership(this.db, room, principal)
|
const member = await this.membership(this.db, room, principal)
|
||||||
const after = sequence(query.after), before = sequence(query.before)
|
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.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.image - 'dataUrl' AS image,m.created_at AS "createdAt",
|
||||||
p.display_name AS "senderName",p.kind AS "senderKind", m.principal_id=${principal.id} AS own
|
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
|
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}
|
WHERE m.room_id=${room.id} AND m.seq>=${member.history_from_seq}
|
||||||
@@ -182,22 +184,45 @@ export class CollaborationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async send(principal: Principal, roomId: string, input: any) {
|
async send(principal: Principal, roomId: string, input: any) {
|
||||||
const body = text(input.text, 10000), clientId = uuid(input.clientId)
|
const clientId = uuid(input.clientId)
|
||||||
|
await this.membership(this.db, await this.roomRow(this.db, roomId), principal, true)
|
||||||
|
const { body, image } = await chatContent(input)
|
||||||
return this.db.transaction(async (db) => {
|
return this.db.transaction(async (db) => {
|
||||||
const room = await this.roomRow(db, roomId, true)
|
const room = await this.roomRow(db, roomId, true)
|
||||||
const member = await this.membership(db, room, principal, 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)
|
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,created_at AS "createdAt" FROM collaboration_messages
|
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
|
||||||
WHERE room_id=${room.id} AND principal_id=${principal.id} AND client_id=${clientId}`)
|
WHERE room_id=${room.id} AND principal_id=${principal.id} AND client_id=${clientId}`)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.body !== body) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409)
|
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, own: true, senderName: principal.display_name, senderKind: principal.kind }
|
return { ...existing, image: imageMetadata(existing.image), 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 [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)
|
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}) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
|
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"`)
|
||||||
await this.options.changed?.(room.id, db)
|
await this.options.changed?.(room.id, db)
|
||||||
return { ...message, own: true, senderName: principal.display_name, senderKind: principal.kind }
|
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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ export function managesProjects(req: any): boolean {
|
|||||||
return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write"))
|
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) {
|
export async function projectChatAccess(db: any, req: any, projectId?: number) {
|
||||||
const tenantId = Number(req.user?.tenant_id)
|
const tenantId = Number(req.user?.tenant_id)
|
||||||
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false)]
|
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false), eq(projects.chatEnabled, true)]
|
||||||
if (projectId !== undefined) conditions.push(eq(projects.id, projectId))
|
if (projectId !== undefined) conditions.push(eq(projects.id, projectId))
|
||||||
const rows = await db.select().from(projects).where(and(...conditions))
|
const rows = await db.select().from(projects).where(and(...conditions))
|
||||||
if (managesProjects(req)) return rows
|
if (managesProjects(req)) return rows
|
||||||
|
|||||||
@@ -111,6 +111,42 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 { 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) {
|
async listForUser(tenantId: number, userId: string, limit = 50) {
|
||||||
return await this.server.db
|
return await this.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -259,7 +295,7 @@ export class NotificationService {
|
|||||||
const defaultPref = defaults.find((pref) => pref.channel === channel)
|
const defaultPref = defaults.find((pref) => pref.channel === channel)
|
||||||
if (defaultPref) return defaultPref.enabled
|
if (defaultPref) return defaultPref.enabled
|
||||||
|
|
||||||
return true
|
return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TimeService } from "./time-v2/service";
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@@ -200,6 +201,14 @@ export const publicLinkService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async submitFormData(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
|
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)
|
// 1. Validierung (Token & PIN)
|
||||||
const linkConfig = await server.db.query.publicLinks.findFirst({
|
const linkConfig = await server.db.query.publicLinks.findFirst({
|
||||||
where: eq(schema.publicLinks.token, token)
|
where: eq(schema.publicLinks.token, token)
|
||||||
@@ -223,7 +232,7 @@ export const publicLinkService = {
|
|||||||
|
|
||||||
// Profil laden, um die user_id zu bekommen
|
// Profil laden, um die user_id zu bekommen
|
||||||
const authProfile = await server.db.query.authProfiles.findFirst({
|
const authProfile = await server.db.query.authProfiles.findFirst({
|
||||||
where: eq(schema.authProfiles.id, rawProfileId)
|
where: and(eq(schema.authProfiles.id, rawProfileId), eq(schema.authProfiles.tenant_id, tenantId))
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!authProfile) throw new Error("Profile_Not_Found");
|
if (!authProfile) throw new Error("Profile_Not_Found");
|
||||||
@@ -245,7 +254,7 @@ export const publicLinkService = {
|
|||||||
// SCHRITT A: Stammdaten laden
|
// SCHRITT A: Stammdaten laden
|
||||||
// =================================================================
|
// =================================================================
|
||||||
const project = await server.db.query.projects.findFirst({
|
const project = await server.db.query.projects.findFirst({
|
||||||
where: eq(schema.projects.id, payload.project)
|
where: and(eq(schema.projects.id, payload.project), eq(schema.projects.tenant, tenantId))
|
||||||
});
|
});
|
||||||
if (!project) throw new Error("Project not found");
|
if (!project) throw new Error("Project not found");
|
||||||
|
|
||||||
@@ -351,29 +360,42 @@ export const publicLinkService = {
|
|||||||
generatedDocumentId: createdDoc.id
|
generatedDocumentId: createdDoc.id
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. START EVENT
|
const timeService = new TimeService(server.db);
|
||||||
await server.db.insert(schema.stafftimeevents).values({
|
const actor = { tenant: tenantId, user: userId, trusted: true };
|
||||||
tenant_id: tenantId,
|
await timeService.lock(server.db, actor, userId);
|
||||||
user_id: userId, // WICHTIG: User ID
|
if (await timeService.active(actor, userId)) {
|
||||||
actortype: "user",
|
await timeService.mutate(actor, userId, `public-link:${createdDoc.id}`, {
|
||||||
actoruser_id: userId, // WICHTIG: User ID
|
action: 'create', data: {
|
||||||
eventtime: startDate,
|
type: 'work', started_at: startDate.toISOString(), ended_at: endDate.toISOString(),
|
||||||
eventtype: "START",
|
description: eventMetadata.description, breaks: [],
|
||||||
source: "PUBLIC_LINK",
|
projects: [{ project_id: project.id, started_at: startDate.toISOString(), ended_at: endDate.toISOString(), notes: eventMetadata.description }],
|
||||||
metadata: eventMetadata // WICHTIG: Schema heißt 'metadata', nicht 'payload'
|
},
|
||||||
});
|
}, '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'
|
||||||
|
});
|
||||||
|
|
||||||
// 2. STOP EVENT
|
// 2. STOP EVENT
|
||||||
await server.db.insert(schema.stafftimeevents).values({
|
await server.db.insert(schema.stafftimeevents).values({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
actortype: "user",
|
actortype: "user",
|
||||||
actoruser_id: userId,
|
actoruser_id: userId,
|
||||||
eventtime: endDate,
|
eventtime: endDate,
|
||||||
eventtype: "STOP",
|
eventtype: "STOP",
|
||||||
source: "PUBLIC_LINK",
|
source: "PUBLIC_LINK",
|
||||||
metadata: eventMetadata
|
metadata: eventMetadata
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|||||||
70
backend/src/modules/scanner-inbox-assignments.ts
Normal file
70
backend/src/modules/scanner-inbox-assignments.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { and, asc, eq, ilike, isNull, sql, type SQL } from "drizzle-orm"
|
||||||
|
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core"
|
||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { authProfiles, checks, contracts, createddocuments, customers, incominginvoices, inventoryitems, plants, products, projects, vehicles, vendors } from "../../db/schema"
|
||||||
|
|
||||||
|
type Target = { label: string; table: AnyPgTable; id: AnyPgColumn; tenant: AnyPgColumn; name: AnyPgColumn; available: SQL; uuid?: boolean }
|
||||||
|
export const assignmentTargets: Record<string, Target> = {
|
||||||
|
project: { label: "Projekt", table: projects, id: projects.id, tenant: projects.tenant, name: projects.name, available: eq(projects.archived, false) },
|
||||||
|
customer: { label: "Kunde", table: customers, id: customers.id, tenant: customers.tenant, name: customers.name, available: eq(customers.archived, false) },
|
||||||
|
vendor: { label: "Lieferant", table: vendors, id: vendors.id, tenant: vendors.tenant, name: vendors.name, available: eq(vendors.archived, false) },
|
||||||
|
vehicle: { label: "Fahrzeug", table: vehicles, id: vehicles.id, tenant: vehicles.tenant, name: vehicles.license_plate, available: eq(vehicles.archived, false) },
|
||||||
|
plant: { label: "Objekt", table: plants, id: plants.id, tenant: plants.tenant, name: plants.name, available: eq(plants.archived, false) },
|
||||||
|
contract: { label: "Vertrag", table: contracts, id: contracts.id, tenant: contracts.tenant, name: contracts.name, available: eq(contracts.archived, false) },
|
||||||
|
product: { label: "Produkt", table: products, id: products.id, tenant: products.tenant, name: products.name, available: eq(products.archived, false) },
|
||||||
|
createddocument: { label: "Ausgangsbeleg", table: createddocuments, id: createddocuments.id, tenant: createddocuments.tenant, name: createddocuments.documentNumber, available: eq(createddocuments.archived, false) },
|
||||||
|
incominginvoice: { label: "Eingangsrechnung", table: incominginvoices, id: incominginvoices.id, tenant: incominginvoices.tenant, name: incominginvoices.reference, available: eq(incominginvoices.archived, false) },
|
||||||
|
inventoryitem: { label: "Inventarartikel", table: inventoryitems, id: inventoryitems.id, tenant: inventoryitems.tenant, name: inventoryitems.name, available: eq(inventoryitems.archived, false) },
|
||||||
|
check: { label: "Überprüfung", table: checks, id: checks.id, tenant: checks.tenant, name: checks.name, available: eq(checks.archived, false), uuid: true },
|
||||||
|
authProfile: { label: "Mitarbeiter", table: authProfiles, id: authProfiles.id, tenant: authProfiles.tenant_id, name: authProfiles.full_name,
|
||||||
|
available: and(eq(authProfiles.active, true), isNull(authProfiles.customer_for_portal)), uuid: true },
|
||||||
|
}
|
||||||
|
export const assignmentKinds = Object.entries(assignmentTargets).map(([value, target]) => ({ value, label: target.label }))
|
||||||
|
export type FileAssignments = Record<string, string | number | null>
|
||||||
|
const invalidAssignment = () => Object.assign(new Error("Die gewählte Zuordnung ist nicht verfügbar."), { statusCode: 400 })
|
||||||
|
const targetFor = (kind: string) => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(assignmentTargets, kind)) throw invalidAssignment()
|
||||||
|
return assignmentTargets[kind]
|
||||||
|
}
|
||||||
|
export function assignmentId(kind: string, id: unknown) {
|
||||||
|
const target = targetFor(kind)
|
||||||
|
if (target.uuid) {
|
||||||
|
if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) throw invalidAssignment()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
if (!/^[1-9]\d*$/.test(String(id)) || !Number.isSafeInteger(Number(id))) throw invalidAssignment()
|
||||||
|
return Number(id)
|
||||||
|
}
|
||||||
|
type Reader = Pick<FastifyInstance["db"], "select">
|
||||||
|
export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "", limit = 100, offset = 0) {
|
||||||
|
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)
|
||||||
|
return rows.map(row => ({ id: String(row.id), name: row.name }))
|
||||||
|
}
|
||||||
|
export async function currentAssignments(db: Reader, tenantId: number, file: Record<string, unknown>) {
|
||||||
|
const assignments = []
|
||||||
|
for (const [kind, target] of Object.entries(assignmentTargets)) {
|
||||||
|
if (file[kind] == null) continue
|
||||||
|
const [entry] = await db.select({ name: target.name }).from(target.table)
|
||||||
|
.where(and(eq(target.id, file[kind]), eq(target.tenant, tenantId), target.available)).limit(1)
|
||||||
|
assignments.push({ kind, id: String(file[kind]), name: entry?.name || (entry ? `#${file[kind]}` : "Nicht mehr verfügbar"), available: Boolean(entry) })
|
||||||
|
}
|
||||||
|
return assignments
|
||||||
|
}
|
||||||
|
export async function validateAssignments(tx: Reader, tenantId: number, assignments: FileAssignments) {
|
||||||
|
const changes: FileAssignments = {}
|
||||||
|
for (const [kind, value] of Object.entries(assignments)) {
|
||||||
|
const target = targetFor(kind)
|
||||||
|
if (value === null) { changes[kind] = null; continue }
|
||||||
|
const id = assignmentId(kind, value)
|
||||||
|
const [row] = await tx.select({ id: target.id }).from(target.table)
|
||||||
|
.where(and(eq(target.id, id), eq(target.tenant, tenantId), target.available)).for("share").limit(1)
|
||||||
|
if (!row) throw invalidAssignment()
|
||||||
|
changes[kind] = id
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
|
||||||
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
|
||||||
import type { FastifyInstance } from "fastify"
|
import type { FastifyInstance } from "fastify"
|
||||||
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||||
import { files, filetags, folders, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema"
|
import { files, filetags, folders, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema"
|
||||||
import { sanitizeFilename } from "../utils/filename"
|
import { sanitizeFilename } from "../utils/filename"
|
||||||
import { s3 } from "../utils/s3"
|
import { s3 } from "../utils/s3"
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
import { suggestDocumentFiling, validateFilingSuggestion, type FilingSuggestionResult } from "./scanner-inbox-suggestions"
|
import { suggestDocumentFiling, validateFilingSuggestion } from "./scanner-inbox-suggestions"
|
||||||
|
import { assignmentKinds, assignmentOptions, currentAssignments, validateAssignments, type FileAssignments } from "./scanner-inbox-assignments"
|
||||||
|
|
||||||
export const SCANNER_UPLOAD_LIMIT = 100 * 1024 * 1024
|
export const SCANNER_UPLOAD_LIMIT = 100 * 1024 * 1024
|
||||||
export const SCANNER_DAV_PATH = "/scanner-inbox/"
|
export const SCANNER_DAV_PATH = "/scanner-inbox/"
|
||||||
@@ -47,7 +48,6 @@ export function validateScannerFile(filename: string, body: Buffer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createScannerInboxService(db: FastifyInstance["db"], storage: ScannerStorage = objectStorage, analyze = suggestDocumentFiling) {
|
export function createScannerInboxService(db: FastifyInstance["db"], storage: ScannerStorage = objectStorage, analyze = suggestDocumentFiling) {
|
||||||
const pendingSuggestions = new Map<string, Promise<FilingSuggestionResult>>()
|
|
||||||
const publicConnection = {
|
const publicConnection = {
|
||||||
username: scannerInboxConnections.username,
|
username: scannerInboxConnections.username,
|
||||||
active: scannerInboxConnections.active,
|
active: scannerInboxConnections.active,
|
||||||
@@ -93,13 +93,19 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
|||||||
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
|
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
|
||||||
search ? ilike(scannerInboxDocuments.remoteFilename, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined)
|
search ? ilike(scannerInboxDocuments.remoteFilename, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined)
|
||||||
const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
|
const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
|
||||||
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt })
|
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt, suggestionStatus: scannerInboxDocuments.suggestionStatus })
|
||||||
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
||||||
.where(condition).orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limit).offset(offset)
|
.where(condition).orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limit).offset(offset)
|
||||||
const [total] = await db.select({ value: count() }).from(scannerInboxDocuments)
|
const [total] = await db.select({ value: count() }).from(scannerInboxDocuments)
|
||||||
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId)).where(condition)
|
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId)).where(condition)
|
||||||
return { documents, total: total.value }
|
return { documents, total: total.value }
|
||||||
}
|
}
|
||||||
|
const openCount = async (tenantId: number) => {
|
||||||
|
const [row] = await db.select({ total: count() }).from(scannerInboxDocuments)
|
||||||
|
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
||||||
|
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId), isNull(scannerInboxDocuments.filedAt), eq(files.archived, false)))
|
||||||
|
return row
|
||||||
|
}
|
||||||
const filingOptions = async (tenantId: number) => {
|
const filingOptions = async (tenantId: number) => {
|
||||||
const [availableFolders, types] = await Promise.all([
|
const [availableFolders, types] = await Promise.all([
|
||||||
db.select({ id: folders.id, name: folders.name, parent: folders.parent,
|
db.select({ id: folders.id, name: folders.name, parent: folders.parent,
|
||||||
@@ -108,33 +114,100 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
|||||||
db.select({ id: filetags.id, name: filetags.name }).from(filetags)
|
db.select({ id: filetags.id, name: filetags.name }).from(filetags)
|
||||||
.where(and(eq(filetags.tenant, tenantId), eq(filetags.archived, false))).orderBy(asc(filetags.name)),
|
.where(and(eq(filetags.tenant, tenantId), eq(filetags.archived, false))).orderBy(asc(filetags.name)),
|
||||||
])
|
])
|
||||||
return { folders: availableFolders, types }
|
return { folders: availableFolders, types, assignmentKinds }
|
||||||
}
|
}
|
||||||
const suggestionDocument = async (tenantId: number, fileId: string) => {
|
const suggestionDocument = async (tenantId: number, fileId: string) => {
|
||||||
const [document] = await db.select({ path: files.path, name: scannerInboxDocuments.remoteFilename, mimeType: files.mimeType, size: files.size })
|
const [document] = await db.select({ path: files.path, name: scannerInboxDocuments.remoteFilename, mimeType: files.mimeType, size: files.size,
|
||||||
|
status: scannerInboxDocuments.suggestionStatus, suggestion: scannerInboxDocuments.suggestion, message: scannerInboxDocuments.suggestionMessage })
|
||||||
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
||||||
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId), eq(files.id, fileId),
|
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId), eq(files.id, fileId),
|
||||||
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false))).limit(1)
|
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false))).limit(1)
|
||||||
if (!document?.path) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
|
if (!document?.path) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
|
||||||
return document
|
return document
|
||||||
}
|
}
|
||||||
const suggestFiling = async (tenantId: number, fileId: string): Promise<FilingSuggestionResult> => {
|
const getSuggestion = async (tenantId: number, fileId: string) => {
|
||||||
// Check tenant ownership before reading storage or submitting anything to the AI provider.
|
|
||||||
const document = await suggestionDocument(tenantId, fileId)
|
const document = await suggestionDocument(tenantId, fileId)
|
||||||
const key = `${tenantId}:${fileId}`
|
const suggestion = document.status === "ready"
|
||||||
if (pendingSuggestions.has(key)) return pendingSuggestions.get(key)!
|
? validateFilingSuggestion({ suggestion: document.suggestion }, await filingOptions(tenantId)) : null
|
||||||
if (pendingSuggestions.size >= 4) throw scannerError(429, "Die KI verarbeitet gerade weitere Dokumente. Bitte gleich erneut versuchen.")
|
return { status: document.status, suggestion, message: document.status === "ready" && !suggestion
|
||||||
const operation = (async () => {
|
? document.message || "Keine passende Zuordnung verfügbar. Bitte manuell auswählen." : document.message }
|
||||||
const result = await analyze(document, await filingOptions(tenantId))
|
|
||||||
await suggestionDocument(tenantId, fileId)
|
|
||||||
const suggestion = validateFilingSuggestion(result, await filingOptions(tenantId))
|
|
||||||
return { suggestion, message: suggestion ? undefined : result.message || "Keine passende Zuordnung verfügbar. Bitte manuell auswählen." }
|
|
||||||
})()
|
|
||||||
pendingSuggestions.set(key, operation)
|
|
||||||
try { return await operation }
|
|
||||||
finally { pendingSuggestions.delete(key) }
|
|
||||||
}
|
}
|
||||||
const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string) => db.transaction(async tx => {
|
const queueSuggestion = async (tenantId: number, fileId: string) => {
|
||||||
|
await suggestionDocument(tenantId, fileId)
|
||||||
|
await db.update(scannerInboxDocuments).set({ suggestionStatus: "pending", suggestion: null, suggestionMessage: null,
|
||||||
|
suggestionClaim: null, suggestionUpdatedAt: new Date() })
|
||||||
|
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId),
|
||||||
|
isNull(scannerInboxDocuments.filedAt), inArray(scannerInboxDocuments.suggestionStatus, ["ready", "failed"])))
|
||||||
|
return getSuggestion(tenantId, fileId)
|
||||||
|
}
|
||||||
|
const processNextSuggestion = async () => {
|
||||||
|
const claim = randomUUID()
|
||||||
|
// Claim without holding a transaction open while OCR/the provider runs. Expired claims survive restarts.
|
||||||
|
const claimed = await db.execute<{ fileId: string; tenantId: string }>(sql`
|
||||||
|
UPDATE scanner_inbox_documents AS job
|
||||||
|
SET suggestion_status = 'processing', suggestion_claim = ${claim}, suggestion_updated_at = now()
|
||||||
|
WHERE job.file_id = (
|
||||||
|
SELECT pending.file_id FROM scanner_inbox_documents AS pending
|
||||||
|
JOIN files ON files.id = pending.file_id AND files.tenant = pending.tenant_id
|
||||||
|
JOIN tenants ON tenants.id = pending.tenant_id
|
||||||
|
WHERE pending.filed_at IS NULL AND files.archived = false
|
||||||
|
AND tenants.locked IS DISTINCT FROM 'maintenance_tenant'
|
||||||
|
AND (pending.suggestion_status = 'pending' OR
|
||||||
|
(pending.suggestion_status = 'processing' AND pending.suggestion_updated_at < now() - interval '5 minutes'))
|
||||||
|
ORDER BY pending.received_at, pending.file_id FOR UPDATE OF pending SKIP LOCKED LIMIT 1
|
||||||
|
) RETURNING job.file_id AS "fileId", job.tenant_id AS "tenantId"`)
|
||||||
|
const job = claimed.rows[0]
|
||||||
|
if (!job) return false
|
||||||
|
const tenantId = Number(job.tenantId)
|
||||||
|
const condition = and(eq(scannerInboxDocuments.fileId, job.fileId), eq(scannerInboxDocuments.tenantId, tenantId),
|
||||||
|
eq(scannerInboxDocuments.suggestionClaim, claim), isNull(scannerInboxDocuments.filedAt))
|
||||||
|
try {
|
||||||
|
const document = await suggestionDocument(tenantId, job.fileId)
|
||||||
|
const { path, name, mimeType, size } = document
|
||||||
|
const result = await analyze({ path, name, mimeType, size }, await filingOptions(tenantId))
|
||||||
|
await suggestionDocument(tenantId, job.fileId)
|
||||||
|
const suggestion = validateFilingSuggestion(result, await filingOptions(tenantId))
|
||||||
|
await db.update(scannerInboxDocuments).set({ suggestionStatus: "ready", suggestion,
|
||||||
|
suggestionMessage: suggestion ? null : result.message || "Keine passende Zuordnung gefunden. Bitte manuell auswählen.",
|
||||||
|
suggestionClaim: null, suggestionUpdatedAt: new Date() }).where(condition)
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.statusCode === 503 ? "Die KI-Erkennung ist noch nicht konfiguriert. Bitte manuell zuordnen oder später erneut prüfen."
|
||||||
|
: "Die KI-Vorbereitung ist fehlgeschlagen. Bitte erneut prüfen oder manuell zuordnen."
|
||||||
|
await db.update(scannerInboxDocuments).set({ suggestionStatus: "failed", suggestion: null, suggestionMessage: message,
|
||||||
|
suggestionClaim: null, suggestionUpdatedAt: new Date() }).where(condition)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const getAssignments = async (tenantId: number, fileId: string) => {
|
||||||
|
await suggestionDocument(tenantId, fileId)
|
||||||
|
const [file] = await db.select().from(files).where(and(eq(files.id, fileId), eq(files.tenant, tenantId), eq(files.archived, false))).limit(1)
|
||||||
|
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)
|
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
|
||||||
.where(eq(tenants.id, tenantId)).for("share")
|
.where(eq(tenants.id, tenantId)).for("share")
|
||||||
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
|
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
|
||||||
@@ -157,11 +230,12 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
|||||||
const [type] = await tx.select({ id: filetags.id }).from(filetags)
|
const [type] = await tx.select({ id: filetags.id }).from(filetags)
|
||||||
.where(and(eq(filetags.id, typeId), eq(filetags.tenant, tenantId), eq(filetags.archived, false))).for("share")
|
.where(and(eq(filetags.id, typeId), eq(filetags.tenant, tenantId), eq(filetags.archived, false))).for("share")
|
||||||
if (!type) throw scannerError(400, "Der gewählte Dokumenttyp ist nicht verfügbar.")
|
if (!type) throw scannerError(400, "Der gewählte Dokumenttyp ist nicht verfügbar.")
|
||||||
|
const assignmentChanges = await validateAssignments(tx, tenantId, assignments)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
await tx.update(files).set({ folder: folderId, type: typeId, updatedAt: now, updatedBy: userId })
|
await tx.update(files).set({ ...assignmentChanges, folder: folderId, type: typeId, updatedAt: now, updatedBy: userId })
|
||||||
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
|
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
|
||||||
// Keep the receipt so a scanner retry cannot recreate a document after filing.
|
// Keep the receipt so a scanner retry cannot recreate a document after filing.
|
||||||
await tx.update(scannerInboxDocuments).set({ filedAt: now })
|
await tx.update(scannerInboxDocuments).set({ filedAt: now, suggestionClaim: null })
|
||||||
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
|
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
|
||||||
return { id: fileId, folder: folderId, type: typeId }
|
return { id: fileId, folder: folderId, type: typeId }
|
||||||
})
|
})
|
||||||
@@ -190,32 +264,34 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
|||||||
// Epson probes writability with an empty PUT. No file, receipt or reservation is stored.
|
// Epson probes writability with an empty PUT. No file, receipt or reservation is stored.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
|
const storeDocument = async (tenantId: number, filename: string, body: Buffer,
|
||||||
|
authorize: (tx: Pick<FastifyInstance["db"], "select">) => Promise<void>, onlyIfNew = false, userId?: string) => {
|
||||||
const contentType = validateScannerFile(filename, body)
|
const contentType = validateScannerFile(filename, body)
|
||||||
const checksum = digest(body)
|
const checksum = digest(body)
|
||||||
let uploadedKey: string | undefined
|
let uploadedKey: string | undefined
|
||||||
try {
|
try {
|
||||||
return await db.transaction(async tx => {
|
return await db.transaction(async tx => {
|
||||||
await assertWritable(tx, identity)
|
await authorize(tx)
|
||||||
|
|
||||||
// Serialise retries across processes; a lost HTTP response must not duplicate a document.
|
// Serialise retries across processes; a lost HTTP response must not duplicate a document.
|
||||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`)
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${tenantId}:${filename}`}, 0))`)
|
||||||
const [existing] = await tx.select().from(scannerInboxDocuments).where(and(
|
const [existing] = await tx.select().from(scannerInboxDocuments).where(and(
|
||||||
eq(scannerInboxDocuments.tenantId, identity.tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
|
eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
|
||||||
)).limit(1)
|
)).limit(1)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (onlyIfNew) throw scannerError(412, "Der Dateiname ist bereits vorhanden.")
|
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.")
|
if (existing.checksum !== checksum) throw scannerError(409, "Der Dateiname ist bereits vergeben. Bitte einen eindeutigen Dateinamen verwenden.")
|
||||||
return { created: false, checksum }
|
return { id: existing.fileId, created: false, checksum }
|
||||||
}
|
}
|
||||||
const id = randomUUID()
|
const id = randomUUID()
|
||||||
const key = `${identity.tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
|
const key = `${tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
|
||||||
uploadedKey = key
|
uploadedKey = key
|
||||||
await storage.put(key, body, contentType)
|
await storage.put(key, body, contentType)
|
||||||
// Bypass saveFile's text extraction: the inbox only stores the original document.
|
// Bypass saveFile's text extraction: the inbox only stores the original document.
|
||||||
await tx.insert(files).values({ id, tenant: identity.tenantId, name: filename, path: key, mimeType: contentType, size: body.length })
|
await tx.insert(files).values({ id, tenant: tenantId, name: filename, path: key, mimeType: contentType, size: body.length,
|
||||||
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId: identity.tenantId, remoteFilename: filename, checksum })
|
...(userId ? { createdBy: userId } : {}) })
|
||||||
return { created: true, checksum }
|
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId, remoteFilename: filename, checksum })
|
||||||
|
return { id, created: true, checksum }
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (uploadedKey) {
|
if (uploadedKey) {
|
||||||
@@ -225,7 +301,17 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { connection, credentials, disable, authenticate, find, list, filingOptions, suggestFiling, fileDocument, probe, accept }
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>
|
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>
|
||||||
|
|||||||
27
backend/src/modules/scanner-inbox.worker.ts
Normal file
27
backend/src/modules/scanner-inbox.worker.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { createScannerInboxService, type ScannerInboxService } from "./scanner-inbox.service"
|
||||||
|
|
||||||
|
export function startScannerInboxWorker(server: FastifyInstance, service: Pick<ScannerInboxService, "processNextSuggestion"> = createScannerInboxService(server.db)) {
|
||||||
|
let stopped = false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let running: Promise<void> | undefined
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
// One analysis per process; database claims coordinate multiple backend instances.
|
||||||
|
while (!stopped && await service.processNextSuggestion()) { /* drain the durable queue */ }
|
||||||
|
} catch {
|
||||||
|
server.log.error("KI-Vorbereitung der Dokumentenablage unterbrochen; erneuter Versuch folgt.")
|
||||||
|
} finally {
|
||||||
|
if (!stopped) {
|
||||||
|
timer = setTimeout(() => { running = run() }, 2000)
|
||||||
|
timer.unref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.addHook("onReady", async () => { running = run() })
|
||||||
|
server.addHook("onClose", async () => {
|
||||||
|
stopped = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
await running
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,6 +14,60 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
|
|||||||
dayjs.extend(quarterOfYear);
|
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 (
|
export const executeManualGeneration = async (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
executionDate: string | Date,
|
executionDate: string | Date,
|
||||||
@@ -66,7 +120,7 @@ export const executeManualGeneration = async (
|
|||||||
.values({
|
.values({
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
executionDate: executionDayjs.toDate(),
|
executionDate: executionDayjs.toDate(),
|
||||||
status: "draft",
|
status: "generating",
|
||||||
createdBy: executedBy,
|
createdBy: executedBy,
|
||||||
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
|
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
|
||||||
})
|
})
|
||||||
@@ -74,50 +128,45 @@ export const executeManualGeneration = async (
|
|||||||
|
|
||||||
console.log(executionRecord);
|
console.log(executionRecord);
|
||||||
|
|
||||||
// 4. Loop durch die Templates
|
try {
|
||||||
for (const template of templates) {
|
// 4. Loop durch die Templates
|
||||||
try {
|
for (const template of templates) {
|
||||||
const resultId = await processSingleTemplate(
|
if (await isCancellationRequested(server, executionRecord.id, tenantId)) break;
|
||||||
server,
|
try {
|
||||||
template,
|
const resultId = await processSingleTemplate(
|
||||||
tenant,
|
server,
|
||||||
executionDayjs,
|
template,
|
||||||
folderId,
|
tenant,
|
||||||
fileTypeId,
|
executionDayjs,
|
||||||
executedBy,
|
folderId,
|
||||||
executionRecord.id
|
fileTypeId,
|
||||||
);
|
executedBy,
|
||||||
results.push({ id: template.id, status: "success", newDocumentId: resultId });
|
executionRecord.id
|
||||||
} catch (e: any) {
|
);
|
||||||
console.error(`Fehler bei Template ${template.id}:`, e);
|
results.push({ id: template.id, status: "success", newDocumentId: resultId });
|
||||||
results.push({ id: template.id, status: "error", error: e.message });
|
} catch (e: any) {
|
||||||
|
console.error(`Fehler bei Template ${template.id}:`, e);
|
||||||
|
results.push({ id: template.id, status: "error", error: e.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { execution: executionRecord, results };
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
|
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 {
|
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.)
|
// Tenant laden (für Settings etc.)
|
||||||
const [tenant] = await server.db
|
const [tenant] = await server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -125,19 +174,13 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
.where(eq(schema.tenants.id, tenantId))
|
.where(eq(schema.tenants.id, tenantId))
|
||||||
.limit(1);
|
.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
|
// 3. Alle erstellten Dokumente dieser Execution laden
|
||||||
const documents = await server.db
|
const documents = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.createddocuments)
|
.from(schema.createddocuments)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(schema.createddocuments.serialexecution, executionId),
|
eq(schema.createddocuments.serialexecution, executionId),
|
||||||
|
eq(schema.createddocuments.state, "Entwurf"),
|
||||||
eq(schema.createddocuments.tenant, tenantId),
|
eq(schema.createddocuments.tenant, tenantId),
|
||||||
eq(schema.createddocuments.archived, false),
|
eq(schema.createddocuments.archived, false),
|
||||||
));
|
));
|
||||||
@@ -160,6 +203,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
|
|
||||||
// 5. Loop durch Dokumente
|
// 5. Loop durch Dokumente
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
|
if (await isCancellationRequested(server, executionId, tenantId)) break;
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const [letterhead] = await Promise.all([
|
const [letterhead] = await Promise.all([
|
||||||
@@ -230,31 +274,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
const finalStatus = errorCount > 0 ? "error" : "completed"; // Oder 'completed' auch wenn Teilerfolge
|
const finalStatus = errorCount > 0 ? "error" : "completed"; // Oder 'completed' auch wenn Teilerfolge
|
||||||
|
|
||||||
|
|
||||||
await server.db
|
const execution = await settleExecution(server, executionId, tenantId, finalStatus,
|
||||||
.update(schema.serialExecutions)
|
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`);
|
||||||
.set({
|
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error("Critical Error in finishManualGeneration:", error);
|
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
|
||||||
// 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,6 +675,7 @@ export function getDocumentDataBackend(
|
|||||||
// --- 7. Construct Final Object ---
|
// --- 7. Construct Final Object ---
|
||||||
|
|
||||||
// Adresse aufbereiten
|
// Adresse aufbereiten
|
||||||
|
const recipientCountry = String(itemInfo.address?.country ?? customerData.infoData?.country ?? customerData.country ?? "").trim();
|
||||||
const recipientArray = [
|
const recipientArray = [
|
||||||
customerData.name,
|
customerData.name,
|
||||||
...(customerData.nameAddition ? [customerData.nameAddition] : []),
|
...(customerData.nameAddition ? [customerData.nameAddition] : []),
|
||||||
@@ -658,6 +683,7 @@ export function getDocumentDataBackend(
|
|||||||
itemInfo.address?.street || customerData.street || "",
|
itemInfo.address?.street || customerData.street || "",
|
||||||
...(itemInfo.address?.special ? [itemInfo.address.special] : []),
|
...(itemInfo.address?.special ? [itemInfo.address.special] : []),
|
||||||
`${itemInfo.address?.zip || customerData.zip} ${itemInfo.address?.city || customerData.city}`,
|
`${itemInfo.address?.zip || customerData.zip} ${itemInfo.address?.city || customerData.city}`,
|
||||||
|
...(recipientCountry && recipientCountry.toLocaleLowerCase("de") !== "deutschland" ? [recipientCountry] : []),
|
||||||
].filter(Boolean); // Leere Einträge entfernen
|
].filter(Boolean); // Leere Einträge entfernen
|
||||||
|
|
||||||
console.log(contactPerson)
|
console.log(contactPerson)
|
||||||
|
|||||||
108
backend/src/modules/time-v2/compatibility.ts
Normal file
108
backend/src/modules/time-v2/compatibility.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
167
backend/src/modules/time-v2/domain.ts
Normal file
167
backend/src/modules/time-v2/domain.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
146
backend/src/modules/time-v2/migration.ts
Normal file
146
backend/src/modules/time-v2/migration.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
189
backend/src/modules/time-v2/service.ts
Normal file
189
backend/src/modules/time-v2/service.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { TimeService } from "../time-v2/service";
|
||||||
|
import { legacySpans } from "../time-v2/compatibility";
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import {and, eq, gte, lte, asc, inArray} from "drizzle-orm";
|
import {and, eq, gte, lte, asc, inArray} from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +15,14 @@ export async function generateTimesEvaluation(
|
|||||||
startDateInput: string,
|
startDateInput: string,
|
||||||
endDateInput: 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 startDate = server.dayjs(startDateInput);
|
||||||
const endDate = server.dayjs(endDateInput);
|
const endDate = server.dayjs(endDateInput);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { requireSystemAdmin } from "../utils/requireSystemAdmin";
|
|||||||
import { buildSystemStatus } from "../modules/system-status.service";
|
import { buildSystemStatus } from "../modules/system-status.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
|
import { getPortalLoginUrl } from "../utils/portalUrl";
|
||||||
|
|
||||||
export default async function adminRoutes(server: FastifyInstance) {
|
export default async function adminRoutes(server: FastifyInstance) {
|
||||||
await server.register(multipart, {
|
await server.register(multipart, {
|
||||||
@@ -931,6 +932,17 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(400).send({ error: "Customer has no email address" });
|
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 generatedPassword = generateRandomPassword(14);
|
||||||
const passwordHash = await hashPassword(generatedPassword);
|
const passwordHash = await hashPassword(generatedPassword);
|
||||||
|
|
||||||
@@ -1086,8 +1098,6 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
|
|
||||||
|
|
||||||
const mailResult = await sendMail(
|
const mailResult = await sendMail(
|
||||||
email,
|
email,
|
||||||
`FEDEO | Einladung ins Kundenportal`,
|
`FEDEO | Einladung ins Kundenportal`,
|
||||||
@@ -1097,7 +1107,8 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
<p><strong>E-Mail:</strong> ${email}</p>
|
<p><strong>E-Mail:</strong> ${email}</p>
|
||||||
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
|
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
|
||||||
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
|
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
|
||||||
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
|
<p><a href="${portalUrl}">Zum Kundenportal anmelden</a></p>
|
||||||
|
<p>Ihre Login-Adresse: ${portalUrl}</p>
|
||||||
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
|
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { authUsers } from "../../../db/schema";
|
|||||||
import { authTenantUsers } from "../../../db/schema";
|
import { authTenantUsers } from "../../../db/schema";
|
||||||
import { tenants } from "../../../db/schema";
|
import { tenants } from "../../../db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { resolveLoginTenant } from "../../utils/loginTenant";
|
||||||
import {
|
import {
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
issueRefreshToken,
|
issueRefreshToken,
|
||||||
@@ -127,7 +128,7 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(401).send({ error: "Invalid credentials" });
|
return reply.code(401).send({ error: "Invalid credentials" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = req.tenant?.id ? Number(req.tenant.id) : null;
|
const tenantId = await resolveLoginTenant(server, user.id, req.tenant?.id ? Number(req.tenant.id) : null);
|
||||||
const token = createAccessToken(user, tenantId);
|
const token = createAccessToken(user, tenantId);
|
||||||
const refreshToken = await issueRefreshToken(server, user.id, tenantId);
|
const refreshToken = await issueRefreshToken(server, user.id, tenantId);
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll
|
|||||||
if (action === "room") return service.room(ctx.principal, id)
|
if (action === "room") return service.room(ctx.principal, id)
|
||||||
if (action === "messages") return service.messages(ctx.principal, id, input)
|
if (action === "messages") return service.messages(ctx.principal, id, input)
|
||||||
if (action === "send") return service.send(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)
|
if (action === "read") return service.markRead(ctx.principal, id, input.seq)
|
||||||
return { members: await service.members(ctx.principal, id) }
|
return { members: await service.members(ctx.principal, id) }
|
||||||
}
|
}
|
||||||
@@ -118,6 +121,9 @@ export async function collaborationRoutes(server: FastifyInstance, options: Coll
|
|||||||
server.get("/collaboration/rooms/:id/messages", async (req) => request(req, "messages"))
|
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/messages", async (req) => request(req, "send"))
|
||||||
server.post("/collaboration/rooms/:id/read", async (req) => request(req, "read"))
|
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"))
|
server.get("/collaboration/rooms/:id/members", async (req) => request(req, "members"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,5 +143,8 @@ 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/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.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.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))
|
server.post("/guest/api/rooms/:id/read", async (req: any) => service.markRead(await principal(req), req.params.id, body(req).seq))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
communicationRoomMembers,
|
communicationRoomMembers,
|
||||||
communicationRoomReads,
|
communicationRoomReads,
|
||||||
communicationRooms,
|
communicationRooms,
|
||||||
|
communicationHiddenRooms,
|
||||||
notificationsItems,
|
notificationsItems,
|
||||||
projects,
|
projects,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access"
|
import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access"
|
||||||
|
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../modules/chat-content"
|
||||||
|
|
||||||
type ChatUser = {
|
type ChatUser = {
|
||||||
userId: string
|
userId: string
|
||||||
@@ -88,13 +90,13 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureGeneralRoom = async (tenantId: number, creatorId: string) => {
|
const ensureGeneralRoom = async (tenantId: number, creatorId: string) => server.db.transaction(async (tx) => {
|
||||||
let [room] = await server.db.select().from(communicationRooms)
|
const generalKey = and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein"))
|
||||||
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein")))
|
let [room] = await tx.select().from(communicationRooms)
|
||||||
.limit(1)
|
.where(generalKey).limit(1).for("update")
|
||||||
|
|
||||||
if (!room) {
|
if (!room) {
|
||||||
const [created] = await server.db.insert(communicationRooms).values({
|
const [created] = await tx.insert(communicationRooms).values({
|
||||||
tenantId,
|
tenantId,
|
||||||
key: "allgemein",
|
key: "allgemein",
|
||||||
name: "Allgemeiner Chat",
|
name: "Allgemeiner Chat",
|
||||||
@@ -104,16 +106,28 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
}).onConflictDoNothing().returning()
|
}).onConflictDoNothing().returning()
|
||||||
room = created
|
room = created
|
||||||
if (!room) {
|
if (!room) {
|
||||||
[room] = await server.db.select().from(communicationRooms)
|
[room] = await tx.select().from(communicationRooms)
|
||||||
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein")))
|
.where(generalKey).limit(1).for("update")
|
||||||
.limit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (room.type !== "general" || room.archived) chatError("Allgemeiner Chat ist nicht verfügbar", 409)
|
// Old Matrix general-room endpoints omitted the type and persisted its default 'room'.
|
||||||
await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId))
|
// Only unarchived, unbound tenant rooms may be adopted; a key collision must not grant access.
|
||||||
|
if (!room || !["general", "room"].includes(room.type) || room.archived
|
||||||
|
|| room.entityType !== null || room.entityId !== null || room.entityUuid !== null) {
|
||||||
|
chatError("Allgemeiner Chat ist nicht verfügbar", 409)
|
||||||
|
}
|
||||||
|
if (room.type === "room") {
|
||||||
|
[room] = await tx.update(communicationRooms).set({ type: "general" })
|
||||||
|
.where(eq(communicationRooms.id, room.id)).returning()
|
||||||
|
}
|
||||||
|
const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers)
|
||||||
|
.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()
|
||||||
return room
|
return room
|
||||||
}
|
})
|
||||||
|
|
||||||
const requireRoom = async (req: any, tenantId: number, userId: string, roomKey: string) => {
|
const requireRoom = async (req: any, tenantId: number, userId: string, roomKey: string) => {
|
||||||
const [row] = await server.db.select({ room: communicationRooms }).from(communicationRooms)
|
const [row] = await server.db.select({ room: communicationRooms }).from(communicationRooms)
|
||||||
@@ -147,7 +161,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
|
const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
|
||||||
const conditions = [eq(communicationMessages.roomId, roomId)]
|
const conditions = [eq(communicationMessages.roomId, roomId)]
|
||||||
if (afterId > 0) conditions.push(gt(communicationMessages.id, afterId))
|
if (afterId > 0) conditions.push(gt(communicationMessages.id, afterId))
|
||||||
const rows = await server.db.select().from(communicationMessages)
|
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)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id))
|
.orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id))
|
||||||
.limit(Math.min(Math.max(limit, 1), 100))
|
.limit(Math.min(Math.max(limit, 1), 100))
|
||||||
@@ -168,6 +185,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
return orderedRows.map((message) => ({
|
return orderedRows.map((message) => ({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
body: message.body,
|
body: message.body,
|
||||||
|
image: imageMetadata(message.image),
|
||||||
sender: message.authorUserId,
|
sender: message.authorUserId,
|
||||||
senderDisplayName: displayName(authorById.get(message.authorUserId) || {}),
|
senderDisplayName: displayName(authorById.get(message.authorUserId) || {}),
|
||||||
timestamp: message.createdAt.getTime(),
|
timestamp: message.createdAt.getTime(),
|
||||||
@@ -188,32 +206,34 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
.from(communicationRoomMembers).where(eq(communicationRoomMembers.roomId, room.id))
|
.from(communicationRoomMembers).where(eq(communicationRoomMembers.roomId, room.id))
|
||||||
const memberIds = new Set(memberRows.map((member) => member.userId))
|
const memberIds = new Set(memberRows.map((member) => member.userId))
|
||||||
const normalizedText = message.body.toLowerCase()
|
const normalizedText = message.body.toLowerCase()
|
||||||
const recipients = users.filter((user) => {
|
const recipients = users.filter((user) => user.userId !== senderId && memberIds.has(user.userId))
|
||||||
if (user.userId === senderId || !memberIds.has(user.userId)) return false
|
const isMentioned = (user: ChatUser) => {
|
||||||
if (room.type === "direct") return true
|
|
||||||
const aliases = [displayName(user), user.fullName, user.firstName, user.email]
|
const aliases = [displayName(user), user.fullName, user.firstName, user.email]
|
||||||
.filter(Boolean).map((value) => String(value).toLowerCase())
|
.filter(Boolean).map((value) => String(value).toLowerCase())
|
||||||
return aliases.some((alias) => normalizedText.includes(`@${alias}`))
|
return aliases.some((alias) => normalizedText.includes(`@${alias}`))
|
||||||
})
|
}
|
||||||
if (!recipients.length) return
|
if (!recipients.length) return
|
||||||
const senderName = sender ? displayName(sender) : "FEDEO"
|
const senderName = sender ? displayName(sender) : "FEDEO"
|
||||||
await notifications.trigger({
|
for (const eventType of ["communication.message.direct", "communication.message.mentioned", "communication.message.room"]) {
|
||||||
tenantId,
|
const targets = recipients.filter((user) => (room.type === "direct" ? "communication.message.direct" : isMentioned(user) ? "communication.message.mentioned" : "communication.message.room") === eventType)
|
||||||
userIds: recipients.map((recipient) => recipient.userId),
|
if (!targets.length) continue
|
||||||
eventType: "communication.message.new",
|
await notifications.trigger({
|
||||||
title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : `${senderName} hat dich erwähnt`,
|
tenantId,
|
||||||
message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body,
|
userIds: targets.map((recipient) => recipient.userId),
|
||||||
payload: {
|
eventType,
|
||||||
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
|
title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : eventType === "communication.message.mentioned" ? `${senderName} hat dich erwähnt` : `Neue Nachricht in ${room.name}`,
|
||||||
roomKey: room.key,
|
message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body,
|
||||||
roomName: room.name,
|
payload: {
|
||||||
roomType: room.type,
|
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
|
||||||
messageId: message.id,
|
roomKey: room.key,
|
||||||
mentioned: room.type !== "direct",
|
roomName: room.name,
|
||||||
direct: room.type === "direct",
|
roomType: room.type,
|
||||||
},
|
messageId: message.id,
|
||||||
channels: ["inapp", "push"],
|
mentioned: eventType === "communication.message.mentioned",
|
||||||
})
|
direct: room.type === "direct",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
server.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden")
|
server.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden")
|
||||||
}
|
}
|
||||||
@@ -232,6 +252,28 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" }))
|
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) => {
|
server.get("/communication/chat/users", async (req: any) => {
|
||||||
const tenantId = await requireTenant(req)
|
const tenantId = await requireTenant(req)
|
||||||
return { users: (await tenantUsers(tenantId)).map((user) => ({
|
return { users: (await tenantUsers(tenantId)).map((user) => ({
|
||||||
@@ -272,6 +314,14 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
return room
|
return room
|
||||||
})
|
})
|
||||||
if (!created) return reply.code(409).send({ error: "Dieser Raumschlüssel ist bereits vergeben" })
|
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 }
|
return { ...roomToApi(created), alreadyExisted: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -421,17 +471,33 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
return { ...roomToApi(room), messages, nextId: messages[messages.length - 1]?.id || afterId }
|
return { ...roomToApi(room), messages, nextId: messages[messages.length - 1]?.id || afterId }
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post("/communication/chat/rooms/:roomKey/messages", async (req: any, reply) => {
|
server.post("/communication/chat/rooms/:roomKey/messages", async (req: any) => {
|
||||||
const tenantId = await requireTenant(req)
|
const tenantId = await requireTenant(req)
|
||||||
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
|
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
|
||||||
const body = String(req.body?.text || "").trim()
|
const { body, image } = await chatContent(req.body)
|
||||||
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({
|
const [created] = await server.db.insert(communicationMessages).values({
|
||||||
tenantId, roomId: room.id, authorUserId: req.user.user_id, body,
|
tenantId, roomId: room.id, authorUserId: req.user.user_id, body, image,
|
||||||
}).returning()
|
}).returning()
|
||||||
await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body })
|
await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body: body || "📷 Bild" })
|
||||||
return { id: created.id, body: created.body, sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true }
|
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)
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => {
|
server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { TimeService, digest, rows } from "../../modules/time-v2/service";
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
|
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
|
||||||
@@ -8,7 +11,8 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
// 1. Timestamp aus dem Body holen (optional)
|
// 1. Timestamp aus dem Body holen (optional)
|
||||||
const { rfid_id, timestamp } = req.body as {
|
const { rfid_id, timestamp, request_id } = req.body as {
|
||||||
|
request_id?: string,
|
||||||
rfid_id: string,
|
rfid_id: string,
|
||||||
timestamp?: number // Kann undefined sein (Live) oder Zahl (Offline)
|
timestamp?: number // Kann undefined sein (Live) oder Zahl (Offline)
|
||||||
};
|
};
|
||||||
@@ -51,11 +55,28 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(400).send(`Profile for Token ${rfid_id} not found`);
|
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)
|
// 4. Letztes Event suchen (für Status-Toggle Work Start/End)
|
||||||
const lastEvent = await server.db
|
const lastEvent = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(stafftimeevents)
|
.from(stafftimeevents)
|
||||||
.where(eq(stafftimeevents.user_id, profile.user_id))
|
.where(and(eq(stafftimeevents.user_id, profile.user_id), eq(stafftimeevents.tenant_id, device.tenant)))
|
||||||
.orderBy(desc(stafftimeevents.eventtime))
|
.orderBy(desc(stafftimeevents.eventtime))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.then(rows => rows[0]);
|
.then(rows => rows[0]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { and, eq } from "drizzle-orm"
|
|||||||
import type { FastifyInstance } from "fastify"
|
import type { FastifyInstance } from "fastify"
|
||||||
import { authProfiles } from "../../db/schema"
|
import { authProfiles } from "../../db/schema"
|
||||||
import { createScannerInboxService, SCANNER_DAV_PATH, SCANNER_UPLOAD_LIMIT, type ScannerInboxService } from "../modules/scanner-inbox.service"
|
import { createScannerInboxService, SCANNER_DAV_PATH, SCANNER_UPLOAD_LIMIT, type ScannerInboxService } from "../modules/scanner-inbox.service"
|
||||||
|
import { assignmentTargets, type FileAssignments } from "../modules/scanner-inbox-assignments"
|
||||||
|
|
||||||
export default async function documentInboxRoutes(server: FastifyInstance, options: { service?: ScannerInboxService }) {
|
export default async function documentInboxRoutes(server: FastifyInstance, options: { service?: ScannerInboxService }) {
|
||||||
const service = options.service || createScannerInboxService(server.db)
|
const service = options.service || createScannerInboxService(server.db)
|
||||||
@@ -25,18 +26,35 @@ export default async function documentInboxRoutes(server: FastifyInstance, optio
|
|||||||
return service.list(req.user.tenant_id!, limit, offset, query.search?.trim() || "")
|
return service.list(req.user.tenant_id!, limit, offset, query.search?.trim() || "")
|
||||||
})
|
})
|
||||||
server.get("/document-inbox/filing-options", async req => service.filingOptions(req.user.tenant_id!))
|
server.get("/document-inbox/filing-options", async req => service.filingOptions(req.user.tenant_id!))
|
||||||
|
server.get("/document-inbox/count", async req => service.openCount(req.user.tenant_id!))
|
||||||
|
server.get<{ Querystring: { kind: string; search?: string } }>("/document-inbox/assignment-options", {
|
||||||
|
schema: { querystring: { type: "object", required: ["kind"], properties: {
|
||||||
|
kind: { type: "string", enum: Object.keys(assignmentTargets) }, search: { type: "string", maxLength: 200 },
|
||||||
|
} } },
|
||||||
|
}, async req => service.assignmentOptions(req.user.tenant_id!, req.query.kind, req.query.search?.trim() || ""))
|
||||||
|
server.get<{ Params: { id: string } }>("/document-inbox/:id/assignments", {
|
||||||
|
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
|
||||||
|
}, async req => service.getAssignments(req.user.tenant_id!, req.params.id))
|
||||||
|
server.get<{ Params: { id: string } }>("/document-inbox/:id/suggestion", {
|
||||||
|
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
|
||||||
|
}, async req => service.getSuggestion(req.user.tenant_id!, req.params.id))
|
||||||
server.post<{ Params: { id: string } }>("/document-inbox/:id/suggestion", {
|
server.post<{ Params: { id: string } }>("/document-inbox/:id/suggestion", {
|
||||||
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
|
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
|
||||||
}, async req => service.suggestFiling(req.user.tenant_id!, req.params.id))
|
}, async (req, reply) => reply.code(202).send(await service.queueSuggestion(req.user.tenant_id!, req.params.id)))
|
||||||
server.post<{ Params: { id: string }; Body: { folder: string | null; type: string } }>("/document-inbox/:id/file", {
|
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: {
|
schema: {
|
||||||
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
|
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
|
||||||
body: { type: "object", required: ["folder", "type"], additionalProperties: false, properties: {
|
body: { type: "object", required: ["folder", "type"], additionalProperties: false, properties: {
|
||||||
folder: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
folder: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
||||||
type: { type: "string", format: "uuid" },
|
type: { type: "string", format: "uuid" },
|
||||||
|
assignments: { type: "object", additionalProperties: false, properties: Object.fromEntries(Object.keys(assignmentTargets).map(kind =>
|
||||||
|
[kind, { anyOf: [{ type: "null" }, { type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER }, { type: "string", minLength: 1, maxLength: 36 }] }])) },
|
||||||
} },
|
} },
|
||||||
},
|
},
|
||||||
}, async req => service.fileDocument(req.user.tenant_id!, req.user.user_id, req.params.id, req.body.folder, req.body.type))
|
}, async req => service.fileDocument(req.user.tenant_id!, req.user.user_id, req.params.id, req.body.folder, req.body.type, req.body.assignments))
|
||||||
server.get("/document-inbox/scanner", async req => ({
|
server.get("/document-inbox/scanner", async req => ({
|
||||||
connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT,
|
connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import timezone from "dayjs/plugin/timezone.js";
|
|||||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||||
import {citys, files} from "../../db/schema";
|
import {citys, files} from "../../db/schema";
|
||||||
import {and, eq, isNull, not} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
@@ -301,6 +301,13 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
|
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) => {
|
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
||||||
const {execution_id} = req.params as { execution_id: string }
|
const {execution_id} = req.params as { execution_id: string }
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { installTimeCompatibility } from "../../modules/time-v2/compatibility";
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import {
|
import {
|
||||||
stafftimeentries,
|
stafftimeentries,
|
||||||
@@ -18,6 +19,7 @@ import {z} from "zod";
|
|||||||
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||||
|
|
||||||
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
||||||
|
installTimeCompatibility(server, true);
|
||||||
|
|
||||||
|
|
||||||
server.post("/staff/time/event", async (req, reply) => {
|
server.post("/staff/time/event", async (req, reply) => {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { assertToolPermission, createMcpContext } from "../mcp/authz"
|
|||||||
import { mcpToolMap, mcpTools } from "../mcp/registry"
|
import { mcpToolMap, mcpTools } from "../mcp/registry"
|
||||||
import { asToolError, asToolResult } from "../mcp/result"
|
import { asToolError, asToolResult } from "../mcp/result"
|
||||||
import { JsonRpcRequest } from "../mcp/types"
|
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"
|
||||||
|
|
||||||
const SUPPORTED_PROTOCOL_VERSIONS = [
|
const SUPPORTED_PROTOCOL_VERSIONS = [
|
||||||
"2025-11-25",
|
"2025-11-25",
|
||||||
@@ -195,7 +200,28 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post("/mcp", async (req, reply) => {
|
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." })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
|
||||||
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
||||||
const requests = Array.isArray(body) ? body : [body]
|
const requests = Array.isArray(body) ? body : [body]
|
||||||
const responses = []
|
const responses = []
|
||||||
@@ -226,7 +252,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
|||||||
name: "fedeo-mcp",
|
name: "fedeo-mcp",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
},
|
},
|
||||||
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.",
|
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.",
|
||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ const requireTenant = (tenantId: number | null) => {
|
|||||||
export default async function notificationsRoutes(server: FastifyInstance) {
|
export default async function notificationsRoutes(server: FastifyInstance) {
|
||||||
const svc = new NotificationService(server, getUserDirectory)
|
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) => {
|
server.get("/notifications", async (req) => {
|
||||||
const limit = Number((req.query as { limit?: string })?.limit || 50)
|
const limit = Number((req.query as { limit?: string })?.limit || 50)
|
||||||
return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit)
|
return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
||||||
|
import { projectChatSettingError } from "../../modules/communication-access"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import {
|
import {
|
||||||
eq,
|
eq,
|
||||||
@@ -942,6 +943,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
const table = config.table;
|
const table = config.table;
|
||||||
|
|
||||||
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
|
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
|
||||||
|
if (resource === "projects") {
|
||||||
|
const error = projectChatSettingError(req, createData)
|
||||||
|
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
||||||
|
}
|
||||||
|
|
||||||
if (resource === "members") {
|
if (resource === "members") {
|
||||||
createData = normalizeMemberPayload(createData)
|
createData = normalizeMemberPayload(createData)
|
||||||
@@ -1095,6 +1100,11 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(404).send({ error: "Resource not found" })
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
let data: Record<string, any> = { ...body }
|
let data: Record<string, any> = { ...body }
|
||||||
delete data.updatedAt
|
delete data.updatedAt
|
||||||
delete data.updated_at
|
delete data.updated_at
|
||||||
|
|||||||
62
backend/src/routes/staff/time-v2.ts
Normal file
62
backend/src/routes/staff/time-v2.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { installTimeCompatibility } from "../../modules/time-v2/compatibility";
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import {
|
import {
|
||||||
stafftimeentries,
|
stafftimeentries,
|
||||||
@@ -18,6 +19,7 @@ import {z} from "zod";
|
|||||||
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||||
|
|
||||||
export default async function staffTimeRoutes(server: FastifyInstance) {
|
export default async function staffTimeRoutes(server: FastifyInstance) {
|
||||||
|
installTimeCompatibility(server);
|
||||||
|
|
||||||
|
|
||||||
server.post("/staff/time/event", async (req, reply) => {
|
server.post("/staff/time/event", async (req, reply) => {
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
|
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 { FastifyInstance } from 'fastify'
|
||||||
import { StaffTimeEntryConnect } from '../../types/staff'
|
import { StaffTimeEntryConnect } from '../../types/staff'
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { stafftimenetryconnects } from "../../../db/schema";
|
import { stafftimenetryconnects } from "../../../db/schema";
|
||||||
|
|
||||||
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
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
|
// ▶ Connect anlegen
|
||||||
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
|
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
|
||||||
@@ -48,7 +70,8 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
|||||||
'/staff/time/connects/:connectId',
|
'/staff/time/connects/:connectId',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { connectId } = req.params
|
const { connectId } = req.params
|
||||||
const patchData = { ...req.body } as any
|
const patchData = Object.fromEntries(Object.entries(req.body).filter(([key]) =>
|
||||||
|
['started_at', 'stopped_at', 'project_id', 'notes'].includes(key))) as any
|
||||||
if (patchData.started_at) patchData.started_at = new Date(patchData.started_at)
|
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.stopped_at) patchData.stopped_at = new Date(patchData.stopped_at)
|
||||||
if (patchData.project_id !== undefined) {
|
if (patchData.project_id !== undefined) {
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ import { GetObjectCommand } from "@aws-sdk/client-s3"
|
|||||||
import { s3 } from "./s3"
|
import { s3 } from "./s3"
|
||||||
import { secrets } from "./secrets"
|
import { secrets } from "./secrets"
|
||||||
|
|
||||||
export const streamToBuffer = async (stream: any): Promise<Buffer> => {
|
export const streamToBuffer = async (stream: any, maxBytes = Infinity): Promise<Buffer> => {
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
|
let size = 0
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Buffer.concat(chunks)
|
return Buffer.concat(chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadFileBuffer = async (path: string): Promise<Buffer> => {
|
export const loadFileBuffer = async (path: string, maxBytes = Infinity): Promise<Buffer> => {
|
||||||
const response = await s3.send(new GetObjectCommand({
|
const response = await s3.send(new GetObjectCommand({
|
||||||
Bucket: secrets.S3_BUCKET,
|
Bucket: secrets.S3_BUCKET,
|
||||||
Key: path,
|
Key: path,
|
||||||
@@ -22,5 +26,5 @@ export const loadFileBuffer = async (path: string): Promise<Buffer> => {
|
|||||||
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
|
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return streamToBuffer(response.Body)
|
return streamToBuffer(response.Body, maxBytes)
|
||||||
}
|
}
|
||||||
|
|||||||
33
backend/src/utils/loginTenant.ts
Normal file
33
backend/src/utils/loginTenant.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -488,6 +488,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
|
|
||||||
const getTitleKey = (row) => `${row.pos} - ${row.text}`
|
const getTitleKey = (row) => `${row.pos} - ${row.text}`
|
||||||
const getTransferSumText = (row) => {
|
const getTransferSumText = (row) => {
|
||||||
|
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) return null
|
||||||
|
|
||||||
const transferSum = invoiceData.total.titleSumsTransfer[getTitleKey(row)]
|
const transferSum = invoiceData.total.titleSumsTransfer[getTitleKey(row)]
|
||||||
|
|
||||||
return transferSum ? `Übertrag: ${transferSum}` : null
|
return transferSum ? `Übertrag: ${transferSum}` : null
|
||||||
|
|||||||
29
backend/src/utils/portalUrl.ts
Normal file
29
backend/src/utils/portalUrl.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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.")
|
||||||
|
}
|
||||||
@@ -302,6 +302,20 @@ export const buildTenantFullExport = async (
|
|||||||
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
|
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")
|
const communicationRoomIds = collectIds(tables.communication_rooms || [], "id")
|
||||||
if (communicationRoomIds.length) {
|
if (communicationRoomIds.length) {
|
||||||
addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds]))
|
addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds]))
|
||||||
|
|||||||
117
backend/tests/chatContent.test.ts
Normal file
117
backend/tests/chatContent.test.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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() }
|
||||||
|
})
|
||||||
120
backend/tests/chatVisibility.test.ts
Normal file
120
backend/tests/chatVisibility.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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() }
|
||||||
|
})
|
||||||
54
backend/tests/collaborationConfig.test.ts
Normal file
54
backend/tests/collaborationConfig.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import test from "node:test"
|
import test from "node:test"
|
||||||
import { generateKeyPairSync, randomUUID } from "node:crypto"
|
import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto"
|
||||||
import Fastify from "fastify"
|
import Fastify from "fastify"
|
||||||
|
import sharp from "sharp"
|
||||||
import { CollaborationService } from "../src/modules/collaboration/service"
|
import { CollaborationService } from "../src/modules/collaboration/service"
|
||||||
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
|
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
|
||||||
import { instanceUrl, publicAddress, signedHeaders, verifyHeaders, Transport } from "../src/modules/collaboration/transport"
|
import { instanceUrl, publicAddress, signedHeaders, verifyHeaders, Transport } from "../src/modules/collaboration/transport"
|
||||||
@@ -104,6 +105,38 @@ test("two independent instances exchange invitations and messages through signed
|
|||||||
const history = (await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages
|
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])
|
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 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" } })
|
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")
|
const live = await b.federation.memberRequest(b.ctx, bindingId, "room")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"
|
|||||||
import Fastify from "fastify"
|
import Fastify from "fastify"
|
||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken"
|
||||||
import cookie from "@fastify/cookie"
|
import cookie from "@fastify/cookie"
|
||||||
|
import sharp from "sharp"
|
||||||
import authPlugin from "../src/plugins/auth"
|
import authPlugin from "../src/plugins/auth"
|
||||||
import { secrets } from "../src/utils/secrets"
|
import { secrets } from "../src/utils/secrets"
|
||||||
import { collaborationRoutes, collaborationGuestRoutes } from "../src/routes/collaboration"
|
import { collaborationRoutes, collaborationGuestRoutes } from "../src/routes/collaboration"
|
||||||
@@ -57,7 +58,19 @@ test("HTTP guest sessions never authorize normal APIs or other subrooms", { skip
|
|||||||
assert.equal(sent.statusCode, 200, sent.body)
|
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])
|
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")
|
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({ 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: "/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() }
|
} finally { secrets.JWT_SECRET = previousSecret; await app.close(); await fixture.close() }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import test from "node:test"
|
import test from "node:test"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
import Fastify from "fastify"
|
import Fastify from "fastify"
|
||||||
import communicationRoutes from "../src/routes/communication"
|
import communicationRoutes from "../src/routes/communication"
|
||||||
import { assertCustomRoomKey } from "../src/modules/communication-access"
|
import { assertCustomRoomKey } from "../src/modules/communication-access"
|
||||||
@@ -10,6 +11,71 @@ test("custom room keys cannot impersonate system rooms", () => {
|
|||||||
assert.doesNotThrow(() => assertCustomRoomKey("baustelle"))
|
assert.doesNotThrow(() => assertCustomRoomKey("baustelle"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("general chat accepts legacy room metadata without exposing protected rooms", { skip: !testDatabaseUrl }, async (t) => {
|
||||||
|
const fixture = await collaborationDatabase(), app = Fastify()
|
||||||
|
const user = randomUUID(), other = randomUUID(), legacyRoom = randomUUID(), otherTenantRoom = randomUUID()
|
||||||
|
try {
|
||||||
|
await fixture.pool.query("INSERT INTO tenants VALUES (1,'Legacy tenant',null),(2,'Other tenant',null)")
|
||||||
|
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'user@example.test'),($2,'other@example.test')", [user, other])
|
||||||
|
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(1,$2),(2,$2)", [user, other])
|
||||||
|
// Matrix's general-room endpoints supplied key/name but omitted type, defaulting to 'room'.
|
||||||
|
await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,topic,type,archived)
|
||||||
|
VALUES ($1,1,'allgemein','Allgemeiner Chat','Existing topic','room',false),
|
||||||
|
($2,2,'allgemein','Allgemeiner Chat',null,'room',false)`, [legacyRoom, otherTenantRoom])
|
||||||
|
await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2)", [legacyRoom, other])
|
||||||
|
await fixture.pool.query("INSERT INTO communication_messages(tenant_id,room_id,author_user_id,body) VALUES (1,$1,$2,'Existing history')", [legacyRoom, other])
|
||||||
|
app.decorate("db", fixture.db)
|
||||||
|
app.addHook("preHandler", async (req: any) => { req.user = { tenant_id: 1, user_id: user }; req.hasPermission = () => false })
|
||||||
|
await app.register(communicationRoutes)
|
||||||
|
|
||||||
|
await t.test("concurrent legacy loads preserve the room, history and tenant boundary", async () => {
|
||||||
|
const responses = await Promise.all([app.inject("/communication/chat/rooms"), app.inject("/communication/chat/rooms")])
|
||||||
|
for (const response of responses) {
|
||||||
|
assert.equal(response.statusCode, 200, response.body)
|
||||||
|
assert.equal(response.json().rooms.length, 1)
|
||||||
|
assert.equal(response.json().rooms[0].id, legacyRoom)
|
||||||
|
assert.equal(response.json().rooms[0].type, "general")
|
||||||
|
assert.equal(response.json().rooms[0].topic, "Existing topic")
|
||||||
|
}
|
||||||
|
const history = await app.inject("/communication/chat/rooms/allgemein/messages")
|
||||||
|
assert.equal(history.statusCode, 200, history.body)
|
||||||
|
assert.equal(history.json().messages[0].body, "Existing history")
|
||||||
|
assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_room_members WHERE room_id=$1", [legacyRoom])).rows[0].count, "2")
|
||||||
|
assert.equal((await fixture.pool.query("SELECT type FROM communication_rooms WHERE id=$1", [otherTenantRoom])).rows[0].type, "room")
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
{ type: "direct" }, { type: "project" }, { type: "room", entityType: "project", entityId: 42 },
|
||||||
|
{ type: "room", entityId: 42 }, { type: "room", entityUuid: other },
|
||||||
|
{ type: "general", entityType: "project", entityId: 42 },
|
||||||
|
{ type: "room", archived: true }, { type: "general", archived: true },
|
||||||
|
]) await t.test(`protected general-key collision is not converted: ${JSON.stringify(candidate)}`, async () => {
|
||||||
|
const roomId = randomUUID()
|
||||||
|
await fixture.pool.query("UPDATE communication_rooms SET key='preserved-legacy' WHERE id=$1", [legacyRoom])
|
||||||
|
await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,type,archived,entity_type,entity_id,entity_uuid)
|
||||||
|
VALUES ($1,1,'allgemein','Protected',$2,$3,$4,$5,$6)`,
|
||||||
|
[roomId, candidate.type, candidate.archived || false, candidate.entityType || null, candidate.entityId || null, candidate.entityUuid || null])
|
||||||
|
await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2)", [roomId, other])
|
||||||
|
try {
|
||||||
|
const response = await app.inject("/communication/chat/rooms")
|
||||||
|
assert.equal(response.statusCode, 409, response.body)
|
||||||
|
const stored = (await fixture.pool.query("SELECT type,archived FROM communication_rooms WHERE id=$1", [roomId])).rows[0]
|
||||||
|
assert.equal(stored.type, candidate.type)
|
||||||
|
assert.equal(stored.archived, candidate.archived || false)
|
||||||
|
assert.deepEqual((await fixture.pool.query("SELECT user_id FROM communication_room_members WHERE room_id=$1", [roomId])).rows.map(m => m.user_id), [other])
|
||||||
|
} finally {
|
||||||
|
await fixture.pool.query("DELETE FROM communication_rooms WHERE id=$1", [roomId])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await t.test("concurrent first use creates one general room", async () => {
|
||||||
|
const responses = await Promise.all([app.inject("/communication/chat/rooms"), app.inject("/communication/chat/rooms")])
|
||||||
|
for (const response of responses) assert.equal(response.statusCode, 200, response.body)
|
||||||
|
assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_rooms WHERE tenant_id=1 AND key='allgemein'")).rows[0].count, "1")
|
||||||
|
})
|
||||||
|
} finally { await app.close(); await fixture.close() }
|
||||||
|
})
|
||||||
|
|
||||||
test("chat routes protect existing memberships and assigned projects", { skip: !testDatabaseUrl }, async () => {
|
test("chat routes protect existing memberships and assigned projects", { skip: !testDatabaseUrl }, async () => {
|
||||||
const fixture = await collaborationDatabase()
|
const fixture = await collaborationDatabase()
|
||||||
const app = Fastify()
|
const app = Fastify()
|
||||||
|
|||||||
126
backend/tests/customerPortalLogin.test.ts
Normal file
126
backend/tests/customerPortalLogin.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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, [])
|
||||||
|
})
|
||||||
@@ -25,7 +25,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
|
|||||||
let suffix = column.primary ? " PRIMARY KEY" : ""
|
let suffix = column.primary ? " PRIMARY KEY" : ""
|
||||||
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
|
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
|
||||||
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
|
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
|
||||||
if (column.getSQLType() === "boolean") suffix += " DEFAULT false"
|
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
|
||||||
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
||||||
})
|
})
|
||||||
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`)
|
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`)
|
||||||
@@ -33,7 +33,10 @@ 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 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(`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"))
|
await pool.query(await readFile(new URL("../../db/migrations/0070_native_communication_chat.sql", import.meta.url), "utf8"))
|
||||||
for (const name of migrations) {
|
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) {
|
||||||
await pool.query(await readFile(new URL(`../../db/migrations/${name}`, import.meta.url), "utf8"))
|
await pool.query(await readFile(new URL(`../../db/migrations/${name}`, import.meta.url), "utf8"))
|
||||||
}
|
}
|
||||||
return { db, pool, close: async () => {
|
return { db, pool, close: async () => {
|
||||||
|
|||||||
219
backend/tests/mcpCalendarStatus.test.ts
Normal file
219
backend/tests/mcpCalendarStatus.test.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
212
backend/tests/mcpDocuments.test.ts
Normal file
212
backend/tests/mcpDocuments.test.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { Readable } from "node:stream"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { getTableColumns } from "drizzle-orm"
|
||||||
|
import { drizzle } from "drizzle-orm/pg-proxy"
|
||||||
|
import { files } from "../db/schema"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
import { createDocumentTools, documentTools } from "../src/mcp/tools/documents"
|
||||||
|
import { assertToolPermission } from "../src/mcp/authz"
|
||||||
|
import { asToolResult } from "../src/mcp/result"
|
||||||
|
import { decodeDocumentUpload, MCP_BODY_LIMIT, MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
|
||||||
|
import { streamToBuffer } from "../src/utils/fileBuffer"
|
||||||
|
import { secrets } from "../src/utils/secrets"
|
||||||
|
import { initS3, s3 } from "../src/utils/s3"
|
||||||
|
import mcpRoutes from "../src/routes/mcp"
|
||||||
|
import authPlugin from "../src/plugins/auth"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
|
const id = "00000000-0000-4000-8000-000000000001"
|
||||||
|
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
|
||||||
|
const contextFor = (db: any) => ({ server: { db }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read", "documents.write"] }) as any
|
||||||
|
const tool = (name: string) => mcpToolMap.get(`documents.${name}`)!
|
||||||
|
const profileDb = (portal: number | null = null) => drizzle(async () => ({ rows: portal ? [[portal]] : [] }))
|
||||||
|
|
||||||
|
test("document tools are registered with read/write permissions and reject insufficient permissions", () => {
|
||||||
|
assert.equal(documentTools.length, 15)
|
||||||
|
for (const entry of documentTools) {
|
||||||
|
assert.equal(mcpToolMap.get(entry.name), entry)
|
||||||
|
assert.equal(entry.inputSchema.additionalProperties, false)
|
||||||
|
const context = contextFor(profileDb())
|
||||||
|
assert.doesNotThrow(() => assertToolPermission(context, entry))
|
||||||
|
assert.throws(() => assertToolPermission({ ...context, permissions: [] }, entry), { statusCode: 403 })
|
||||||
|
assert.doesNotThrow(() => assertToolPermission({ ...context, permissions: [], isAdmin: true }, entry))
|
||||||
|
}
|
||||||
|
for (const name of ["files.upload", "inbox.file", "inbox.archive", "inbox.suggestion.refresh"]) {
|
||||||
|
assert.deepEqual(tool(name).requiredPermissions, ["documents.write"])
|
||||||
|
}
|
||||||
|
assert.deepEqual(tool("files.download").requiredPermissions, ["documents.read"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validates actual upload bytes, strict Base64, filename and size before storage", () => {
|
||||||
|
assert.deepEqual(decodeDocumentUpload("scan.pdf", pdf.toString("base64")), { body: pdf, mimeType: "application/pdf" })
|
||||||
|
for (const value of ["", null, 42, "!!!!", "Zg==junk", "Zh==", "data:application/pdf;base64," + pdf.toString("base64")]) {
|
||||||
|
assert.throws(() => decodeDocumentUpload("scan.pdf", value), /Base64/)
|
||||||
|
}
|
||||||
|
for (const filename of ["../scan.pdf", "sub/scan.pdf", ".hidden.pdf", "x".repeat(201) + ".pdf", "scan.exe"]) {
|
||||||
|
assert.throws(() => decodeDocumentUpload(filename, pdf.toString("base64")))
|
||||||
|
}
|
||||||
|
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.from("not a PDF").toString("base64")), /vollständige PDF/)
|
||||||
|
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.from("%PDF-1.7 truncated").toString("base64")), /vollständige PDF/)
|
||||||
|
assert.throws(() => decodeDocumentUpload("scan.pdf", "A".repeat(Math.ceil(MCP_FILE_LIMIT / 3) * 4 + 4)), /20 MiB/)
|
||||||
|
const boundary = Buffer.alloc(MCP_FILE_LIMIT, 32)
|
||||||
|
boundary.write("%PDF-1.7\n")
|
||||||
|
boundary.write("%%EOF", boundary.length - 5)
|
||||||
|
assert.equal(decodeDocumentUpload("scan.pdf", boundary.toString("base64")).body.length, MCP_FILE_LIMIT)
|
||||||
|
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.concat([boundary, Buffer.from("\n")]).toString("base64")), /20 MiB/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects unknown system fields, invalid filters, missing IDs and portal access", async () => {
|
||||||
|
const context = contextFor(profileDb())
|
||||||
|
for (const [name, args] of [
|
||||||
|
["files.upload", { filename: "scan.pdf", contentBase64: pdf.toString("base64"), tenant: 9 }],
|
||||||
|
["files.get", {}], ["files.get", { id: "bad" }], ["files.list", { folder: "bad" }],
|
||||||
|
["files.list", { project: -1 }], ["files.list", { limit: 101 }], ["inbox.list", { search: "x".repeat(201) }],
|
||||||
|
["inbox.file", { id, folder: null, type: id, assignments: { tenant: 9 } }],
|
||||||
|
["inbox.file", { id, folder: null, type: id, assignments: { project: "12" } }],
|
||||||
|
["assignments.search", { kind: "__proto__" }], ["files.download", { id, format: "pdf" }],
|
||||||
|
] as const) await assert.rejects(tool(name).handler(context, args))
|
||||||
|
await assert.rejects(tool("inbox.count").handler(contextFor(profileDb(123)), {}), { statusCode: 403 })
|
||||||
|
await assert.rejects(tool("files.download").handler(contextFor(profileDb(123)), { id }), { statusCode: 403 })
|
||||||
|
await assert.rejects(tool("files.upload").handler(contextFor(profileDb(123)), { filename: "scan.pdf", contentBase64: pdf.toString("base64") }), { statusCode: 403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const name of ["inbox.list", "files.list", "folders.list", "types.list", "assignments.search"]) {
|
||||||
|
test(`${name}: retrieves 205 records with tenant filters and stable pagination`, async () => {
|
||||||
|
const db = drizzle(async (sql, params) => {
|
||||||
|
if (sql.includes('from "auth_profiles"')) return { rows: [] }
|
||||||
|
assert.match(sql, /"tenant(?:_id)?" = \$\d+/)
|
||||||
|
assert.ok(params.includes(7))
|
||||||
|
assert.match(sql, /"archived" = \$\d+/)
|
||||||
|
assert.match(sql, /order by .*"id"/)
|
||||||
|
if (name === "inbox.list") {
|
||||||
|
assert.match(sql, /"filed_at" is null/)
|
||||||
|
assert.match(sql, /"files"\."tenant" =/)
|
||||||
|
}
|
||||||
|
if (name === "files.list") {
|
||||||
|
assert.match(sql, /"folder" is null/)
|
||||||
|
assert.match(sql, /"createddocument" =/)
|
||||||
|
assert.ok(params.includes(42))
|
||||||
|
}
|
||||||
|
const limit = Number(params[Number(sql.match(/limit \$(\d+)/)![1]) - 1])
|
||||||
|
const offset = Number(params[Number(sql.match(/offset \$(\d+)/)?.[1]) - 1] || 0)
|
||||||
|
assert.equal(limit, 101)
|
||||||
|
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
|
||||||
|
const idIndex = selected.findIndex(column => /"id"$/.test(column))
|
||||||
|
return { rows: Array.from({ length: Math.min(limit, 205 - offset) }, (_, i) => {
|
||||||
|
const values = selected.map(() => null as any)
|
||||||
|
values[idIndex] = String(i + offset + 1)
|
||||||
|
return values
|
||||||
|
}) }
|
||||||
|
})
|
||||||
|
const ids: string[] = []
|
||||||
|
for (const offset of [0, 100, 200]) {
|
||||||
|
const args = { limit: 100, offset, ...(name === "assignments.search" ? { kind: "project" } : {}),
|
||||||
|
...(name === "files.list" ? { folder: null, createddocument: 42 } : {}) }
|
||||||
|
const result: any = await tool(name).handler(contextFor(db), args)
|
||||||
|
ids.push(...result.rows.map((row: any) => String(row.id)))
|
||||||
|
assert.equal(result.pagination.hasMore, offset < 200)
|
||||||
|
assert.equal(result.pagination.nextOffset, offset < 200 ? offset + 100 : null)
|
||||||
|
}
|
||||||
|
assert.deepEqual(ids, Array.from({ length: 205 }, (_, i) => String(i + 1)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("archive confirmation survives MCP's archived-record filter and upload forwards the active user", async () => {
|
||||||
|
const calls: any[] = []
|
||||||
|
const tools = createDocumentTools({ inbox: () => ({
|
||||||
|
archiveDocument: async (...args: any[]) => { calls.push(args); return { id, archived: true } },
|
||||||
|
upload: async (...args: any[]) => { calls.push(args); return { id, created: true, checksum: "hash" } },
|
||||||
|
}) as any })
|
||||||
|
const context = contextFor(profileDb())
|
||||||
|
const archived = await tools.find(t => t.name.endsWith("inbox.archive"))!.handler(context, { id })
|
||||||
|
assert.deepEqual(asToolResult(archived).structuredContent, { id, success: true, action: "archived" })
|
||||||
|
assert.deepEqual(calls[0], [7, id, id])
|
||||||
|
await tools.find(t => t.name.endsWith("files.upload"))!.handler(context, { filename: "scan.pdf", contentBase64: pdf.toString("base64") })
|
||||||
|
assert.deepEqual(calls[1], [7, id, "scan.pdf", pdf])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("bounded downloads stop a stream even when persisted size is missing or incorrect", async () => {
|
||||||
|
let closed = false
|
||||||
|
async function* chunks() { try { yield Buffer.alloc(4); yield Buffer.alloc(4); yield Buffer.alloc(4) } finally { closed = true } }
|
||||||
|
await assert.rejects(streamToBuffer(chunks(), 5), /zu groß/)
|
||||||
|
assert.equal(closed, true)
|
||||||
|
assert.deepEqual(await streamToBuffer(Readable.from([pdf]), pdf.length), pdf)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without exposing storage paths", async t => {
|
||||||
|
const apiBase = secrets.API_BASE_URL
|
||||||
|
secrets.API_BASE_URL = "https://app.fedeo.test/backend/"
|
||||||
|
t.after(() => { secrets.API_BASE_URL = apiBase })
|
||||||
|
const row = { id, tenant: 7, name: "scan.pdf", mimeType: "application/pdf", size: pdf.length, archived: false, path: `7/filesbyid/${id}/scan.pdf` }
|
||||||
|
let visible = true
|
||||||
|
let portal = false
|
||||||
|
let admin = true
|
||||||
|
const bearer = "fedeo_mcp_test_documents_token"
|
||||||
|
const headers = { authorization: `Bearer ${bearer}` }
|
||||||
|
const db = drizzle(async (sql, params) => {
|
||||||
|
if (sql.includes('from "m2m_api_keys"')) {
|
||||||
|
assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex")))
|
||||||
|
return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] }
|
||||||
|
}
|
||||||
|
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
|
||||||
|
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
|
||||||
|
if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] }
|
||||||
|
if (sql.includes('from "auth_role_permissions"')) return { rows: [] }
|
||||||
|
if (sql.includes('from "auth_profiles"')) return { rows: portal ? [[123]] : [] }
|
||||||
|
assert.match(sql, /from "files"/)
|
||||||
|
assert.match(sql, /"tenant" =/)
|
||||||
|
assert.match(sql, /"archived" =/)
|
||||||
|
assert.ok(params.includes(7))
|
||||||
|
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
|
||||||
|
const columns = getTableColumns(files)
|
||||||
|
return { rows: visible ? [selected.map(column => {
|
||||||
|
const name = column.match(/"([^"]+)"$/)![1]
|
||||||
|
const key = Object.keys(columns).find(key => columns[key].name === name)!
|
||||||
|
return row[key] ?? null
|
||||||
|
})] : [] }
|
||||||
|
})
|
||||||
|
const app = Fastify()
|
||||||
|
t.after(() => app.close())
|
||||||
|
app.decorate("db", db as any)
|
||||||
|
await app.register(authPlugin)
|
||||||
|
await app.register(mcpRoutes, { prefix: "/api" })
|
||||||
|
await initS3()
|
||||||
|
t.mock.method(s3, "send", async command => {
|
||||||
|
assert.equal(command.input.Key, row.path)
|
||||||
|
return { Body: Readable.from([pdf]) }
|
||||||
|
})
|
||||||
|
const call = (name: string, args: any) => app.inject({ method: "POST", url: "/api/mcp", headers,
|
||||||
|
payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } } })
|
||||||
|
const download = await call("documents.files.download", { id })
|
||||||
|
assert.equal(download.statusCode, 200)
|
||||||
|
assert.equal(download.json().result.structuredContent.authentication, "Bearer")
|
||||||
|
assert.equal(download.json().result.structuredContent.url, `https://app.fedeo.test/backend/api/mcp/files/${id}/content`)
|
||||||
|
assert.ok(!download.body.includes("filesbyid"))
|
||||||
|
const url = `/api/mcp/files/${id}/content`
|
||||||
|
assert.equal((await app.inject(url)).statusCode, 401)
|
||||||
|
const binary = await app.inject({ url, headers })
|
||||||
|
assert.equal(binary.statusCode, 200, binary.body)
|
||||||
|
assert.deepEqual(binary.rawPayload, pdf)
|
||||||
|
assert.equal(binary.headers["content-type"], "application/pdf")
|
||||||
|
assert.match(binary.headers["content-disposition"] as string, /attachment; filename="scan.pdf"/)
|
||||||
|
assert.equal(binary.headers["cache-control"], "no-store")
|
||||||
|
const base64 = await call("documents.files.download", { id, format: "base64" })
|
||||||
|
assert.deepEqual(Buffer.from(base64.json().result.structuredContent.contentBase64, "base64"), pdf)
|
||||||
|
admin = false
|
||||||
|
assert.equal((await app.inject({ url, headers })).statusCode, 403)
|
||||||
|
assert.equal((await call("documents.files.upload", { filename: "scan.pdf", contentBase64: pdf.toString("base64") })).json().error.code, -32003)
|
||||||
|
admin = true
|
||||||
|
portal = true
|
||||||
|
assert.equal((await app.inject({ url, headers })).statusCode, 403)
|
||||||
|
portal = false
|
||||||
|
visible = false
|
||||||
|
assert.equal((await app.inject({ url, headers })).statusCode, 404)
|
||||||
|
assert.equal((await call("documents.files.download", { id })).json().result.isError, true)
|
||||||
|
const largeInvalidUpload = await call("documents.files.upload", { filename: "scan.pdf", contentBase64: "A".repeat(2 * 1024 * 1024) })
|
||||||
|
assert.equal(largeInvalidUpload.statusCode, 200, "the request must reach file validation, not Fastify's default 1 MiB limit")
|
||||||
|
assert.equal(largeInvalidUpload.json().result.isError, true)
|
||||||
|
const oversized = await app.inject({ method: "POST", url: "/api/mcp", headers: { "content-type": "application/json", "content-length": String(MCP_BODY_LIMIT + 1) }, payload: "{}" })
|
||||||
|
assert.equal(oversized.statusCode, 413)
|
||||||
|
})
|
||||||
@@ -10,7 +10,9 @@ const tables = Object.values(schema).filter((value: any) => {
|
|||||||
try { return typeof getTableName(value) === "string" } catch { return false }
|
try { return typeof getTableName(value) === "string" } catch { return false }
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset)) {
|
// Document/status tools load related records; their dedicated tests cover filtered pagination.
|
||||||
|
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset && !tool.name.startsWith("documents.")
|
||||||
|
&& tool.name !== "accounting.outgoing_documents.status.list")) {
|
||||||
test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => {
|
test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => {
|
||||||
const db = drizzle(async (sql, params) => {
|
const db = drizzle(async (sql, params) => {
|
||||||
if (sql.includes('from "tenants"')) return { rows: [["skr03"]] }
|
if (sql.includes('from "tenants"')) return { rows: [["skr03"]] }
|
||||||
@@ -42,7 +44,7 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any
|
|||||||
const ids: unknown[] = []
|
const ids: unknown[] = []
|
||||||
let offset = 0
|
let offset = 0
|
||||||
for (let page = 0; page < 3; page++) {
|
for (let page = 0; page < 3; page++) {
|
||||||
const result: any = await tool.handler(context, { id: 1, limit: 100, offset })
|
const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset })
|
||||||
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
assert.equal(result.rows.length, page < 2 ? 100 : 5)
|
||||||
assert.equal(result.pagination.hasMore, page < 2)
|
assert.equal(result.pagination.hasMore, page < 2)
|
||||||
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)
|
||||||
|
|||||||
59
backend/tests/notificationPreferences.test.ts
Normal file
59
backend/tests/notificationPreferences.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import routes from '../src/routes/notifications'
|
||||||
|
import { NotificationService } from '../src/modules/notification.service'
|
||||||
|
import { notificationsEventTypes, notificationsPreferences } from '../db/schema'
|
||||||
|
|
||||||
|
function fixture(prefs: any[] = [], defaults: any[] = []) {
|
||||||
|
const writes: any[] = []
|
||||||
|
const event = { eventKey: 'communication.message.direct', displayName: 'Direktnachrichten', category: 'communication', allowedChannels: ['inapp', 'email', 'push'], isActive: true }
|
||||||
|
const db: any = {
|
||||||
|
select: () => ({ from: (table: any) => ({ where: async () => table === notificationsEventTypes ? [event] : table === notificationsPreferences ? prefs : defaults }) }),
|
||||||
|
transaction: async (callback: any) => callback(db),
|
||||||
|
insert: () => ({ values: (value: any) => ({ onConflictDoUpdate: async () => { writes.push(value) } }) }),
|
||||||
|
}
|
||||||
|
return { service: new NotificationService({ db } as any, async () => null), db, writes }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('defaults are quiet for email, user overrides win over tenant defaults', async () => {
|
||||||
|
assert.deepEqual((await fixture().service.preferences(1, 'user')).events[0].channels, { email: false, push: true })
|
||||||
|
const { service } = fixture([{ channel: 'push', enabled: false }], [{ channel: 'push', enabled: true }, { channel: 'email', enabled: true }])
|
||||||
|
assert.deepEqual((await service.preferences(1, 'user')).events[0].channels, { email: true, push: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalid batches cause no writes, including duplicates and unsupported channels', async () => {
|
||||||
|
for (const bad of [null, {}, [{ eventType: 'unknown', channel: 'push', enabled: true }],
|
||||||
|
[{ eventType: 'communication.message.direct', channel: 'sms', enabled: true }],
|
||||||
|
[{ eventType: 'communication.message.direct', channel: 'push', enabled: 'false' }],
|
||||||
|
Array(2).fill({ eventType: 'communication.message.direct', channel: 'push', enabled: true })]) {
|
||||||
|
const { service, writes } = fixture()
|
||||||
|
await assert.rejects(service.savePreferences(1, 'user', bad), { statusCode: 400 })
|
||||||
|
assert.equal(writes.length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('API ignores supplied tenant/user identifiers and saves in the authenticated context', async () => {
|
||||||
|
const { db, writes } = fixture()
|
||||||
|
const app = Fastify()
|
||||||
|
app.decorate('db', db)
|
||||||
|
app.addHook('preHandler', async (req: any) => { req.user = { tenant_id: 7, user_id: 'current-user' } })
|
||||||
|
await app.register(routes)
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: 'PUT', url: '/notifications/preferences', payload: [
|
||||||
|
{ tenantId: 999, userId: 'other', eventType: 'communication.message.direct', channel: 'push', enabled: false },
|
||||||
|
] })
|
||||||
|
assert.equal(response.statusCode, 200, response.body)
|
||||||
|
assert.deepEqual(writes, [{ tenantId: 7, userId: 'current-user', eventType: 'communication.message.direct', channel: 'push', enabled: false }])
|
||||||
|
const invalid = await app.inject({ method: 'PUT', url: '/notifications/preferences', payload: [{ eventType: 'system.test_push', channel: 'push', enabled: false }] })
|
||||||
|
assert.equal(invalid.statusCode, 400)
|
||||||
|
} finally { await app.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('room notifications stay opt-in while direct messages remain enabled', async () => {
|
||||||
|
const { service } = fixture()
|
||||||
|
const resolve = (eventType: string) => (service as any).resolveEnabledChannels({ tenantId: 1, userId: 'user', eventType, channels: ['email', 'push'] })
|
||||||
|
assert.deepEqual(await resolve('communication.message.room'), [])
|
||||||
|
assert.deepEqual(await resolve('communication.room.invited'), [])
|
||||||
|
assert.deepEqual(await resolve('communication.message.direct'), ['push'])
|
||||||
|
})
|
||||||
88
backend/tests/pdfTransferSums.test.ts
Normal file
88
backend/tests/pdfTransferSums.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { Readable } from "node:stream"
|
||||||
|
import test from "node:test"
|
||||||
|
import { PDFDocument, PDFPage } from "pdf-lib"
|
||||||
|
import { createInvoicePDF } from "../src/utils/pdf"
|
||||||
|
import { initS3, s3 } from "../src/utils/s3"
|
||||||
|
|
||||||
|
const createDocumentData = (type: string) => {
|
||||||
|
const item = (pos: string) => ({
|
||||||
|
mode: "normal",
|
||||||
|
pos,
|
||||||
|
text: `Artikel ${pos}`,
|
||||||
|
quantity: "1",
|
||||||
|
unit: "Stk",
|
||||||
|
taxPercent: 19,
|
||||||
|
price: "25,00 €",
|
||||||
|
rowAmount: "25,00 €",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
adressLine: "Musterfirma, Musterweg 1, 12345 Musterstadt",
|
||||||
|
recipient: ["Musterkunde"],
|
||||||
|
info: [],
|
||||||
|
title: type === "packingSlips" ? "Packschein" : type === "deliveryNotes" ? "Lieferschein" : "Rechnung",
|
||||||
|
description: "Abschnitte mit Seitenumbruch",
|
||||||
|
startText: "Ihre Artikel:",
|
||||||
|
endText: "Vielen Dank.",
|
||||||
|
rows: [
|
||||||
|
{ mode: "title", pos: "1", text: "Abschnitt A" },
|
||||||
|
item("1.1"),
|
||||||
|
{ mode: "title", pos: "2", text: "Abschnitt B" },
|
||||||
|
item("2.1"),
|
||||||
|
{ mode: "pagebreak" },
|
||||||
|
{ mode: "title", pos: "3", text: "Abschnitt C" },
|
||||||
|
item("3.1"),
|
||||||
|
],
|
||||||
|
total: {
|
||||||
|
titleSums: {
|
||||||
|
"1 - Abschnitt A": "25,00 €",
|
||||||
|
"2 - Abschnitt B": "25,00 €",
|
||||||
|
"3 - Abschnitt C": "25,00 €",
|
||||||
|
},
|
||||||
|
titleSumsTransfer: {
|
||||||
|
"2 - Abschnitt B": "25,00 €",
|
||||||
|
"3 - Abschnitt C": "50,00 €",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
totalArray: [{ label: "Nettobetrag", content: "75,00 €" }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of ["packingSlips", "deliveryNotes", "invoices"]) {
|
||||||
|
test(`${type}: renders transfers only for priced documents, including before page breaks`, async t => {
|
||||||
|
const background = await PDFDocument.create()
|
||||||
|
background.addPage().drawText("Musterfirma", { x: 60, y: 800, size: 12 })
|
||||||
|
const backgroundBytes = await background.save()
|
||||||
|
|
||||||
|
await initS3()
|
||||||
|
t.mock.method(s3, "send", async () => ({ Body: Readable.from([backgroundBytes]) }))
|
||||||
|
const drawText = t.mock.method(PDFPage.prototype, "drawText")
|
||||||
|
|
||||||
|
const result = await createInvoicePDF(null, "base64", createDocumentData(type), "test-letterhead.pdf")
|
||||||
|
assert.equal(result?.mimeType, "application/pdf")
|
||||||
|
const pdf = await PDFDocument.load(Buffer.from(result!.base64, "base64"))
|
||||||
|
assert.equal(pdf.getPageCount(), 2)
|
||||||
|
|
||||||
|
const texts = drawText.mock.calls.map(call => call.arguments[0])
|
||||||
|
for (const text of ["Abschnitt A", "Abschnitt B", "Abschnitt C", "Artikel 1.1", "Artikel 2.1", "Artikel 3.1"]) {
|
||||||
|
assert.ok(texts.includes(text), `Missing document content: ${text}`)
|
||||||
|
}
|
||||||
|
assert.equal(texts.filter(text => text === "1 Stk").length, 3)
|
||||||
|
assert.equal(texts.filter(text => text === "Check").length, type === "packingSlips" ? 2 : 0)
|
||||||
|
|
||||||
|
const transfers = texts.filter(text => text.startsWith("Übertrag:"))
|
||||||
|
if (type === "invoices") {
|
||||||
|
assert.deepEqual(transfers, ["Übertrag: 25,00 €", "Übertrag: 50,00 €"])
|
||||||
|
assert.ok(texts.includes("75,00 €"))
|
||||||
|
assert.equal(texts.filter(text => text === "Einheitspreis").length, 2)
|
||||||
|
} else {
|
||||||
|
assert.deepEqual(transfers, [])
|
||||||
|
assert.ok(texts.every(text => !text.includes("€")), "Delivery documents must not display monetary amounts")
|
||||||
|
for (const label of ["Steuer", "Einheitspreis", "Gesamt", "Nettobetrag"]) {
|
||||||
|
assert.ok(!texts.includes(label), `Unexpected price label: ${label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import inboxRoutes from "../src/routes/documentInbox"
|
|||||||
import corsPlugin from "../src/plugins/cors"
|
import corsPlugin from "../src/plugins/cors"
|
||||||
import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, validateScannerFile, type ScannerInboxService } from "../src/modules/scanner-inbox.service"
|
import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, validateScannerFile, type ScannerInboxService } from "../src/modules/scanner-inbox.service"
|
||||||
import type { FilingSuggestionResult } from "../src/modules/scanner-inbox-suggestions"
|
import type { FilingSuggestionResult } from "../src/modules/scanner-inbox-suggestions"
|
||||||
|
import { createDocumentTools } from "../src/mcp/tools/documents"
|
||||||
|
import { assertToolPermission } from "../src/mcp/authz"
|
||||||
|
|
||||||
const execute = promisify(execFile)
|
const execute = promisify(execFile)
|
||||||
const basic = (username = "scanner", password = "secret") => ({ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` })
|
const basic = (username = "scanner", password = "secret") => ({ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` })
|
||||||
@@ -193,10 +195,14 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
CREATE TABLE filetags (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
CREATE TABLE filetags (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||||
CREATE TABLE folders (id uuid PRIMARY KEY, tenant bigint, name text, parent uuid, "standardFiletype" uuid,
|
CREATE TABLE folders (id uuid PRIMARY KEY, tenant bigint, name text, parent uuid, "standardFiletype" uuid,
|
||||||
"standardFiletypeIsOptional" boolean DEFAULT true, archived boolean DEFAULT false);
|
"standardFiletypeIsOptional" boolean DEFAULT true, archived boolean DEFAULT false);
|
||||||
CREATE TABLE auth_profiles (tenant_id bigint, user_id uuid, customer_for_portal bigint);`)
|
CREATE TABLE projects (id bigint PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||||
|
CREATE TABLE customers (id bigint PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||||
|
CREATE TABLE checks (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||||
|
CREATE TABLE auth_profiles (id uuid DEFAULT gen_random_uuid(), tenant_id bigint, user_id uuid, customer_for_portal bigint,
|
||||||
|
full_name text, active boolean DEFAULT true);`)
|
||||||
const migrationRoot = path.resolve(__dirname, "../db/migrations")
|
const migrationRoot = path.resolve(__dirname, "../db/migrations")
|
||||||
const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8"))
|
const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8"))
|
||||||
const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing"].map(tag => journal.entries.find((item: any) => item.tag === tag))
|
const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing", "0077_scanner_inbox_preparation"].map(tag => journal.entries.find((item: any) => item.tag === tag))
|
||||||
await mkdir(path.join(tempMigrations, "meta"))
|
await mkdir(path.join(tempMigrations, "meta"))
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
assert.ok(entry)
|
assert.ok(entry)
|
||||||
@@ -213,14 +219,17 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
const analyses: any[] = []
|
const analyses: any[] = []
|
||||||
let analysisGate: Promise<void> | undefined
|
let analysisGate: Promise<void> | undefined
|
||||||
let analysisStarted: (() => void) | undefined
|
let analysisStarted: (() => void) | undefined
|
||||||
|
let analysisFailure = false
|
||||||
const service = createScannerInboxService(db, {
|
const service = createScannerInboxService(db, {
|
||||||
async put(key, body) { stored.set(key, Buffer.from(body)); if (storageFailure) throw new Error("Simulated object store failure") },
|
async put(key, body) { stored.set(key, Buffer.from(body)); if (storageFailure) throw new Error("Simulated object store failure") },
|
||||||
async remove(key) { stored.delete(key) },
|
async remove(key) { stored.delete(key) },
|
||||||
}, async (document, candidates) => {
|
}, async (document, candidates) => {
|
||||||
analyses.push({ document, candidates })
|
analyses.push({ document, candidates })
|
||||||
|
const result = aiResult
|
||||||
analysisStarted?.()
|
analysisStarted?.()
|
||||||
await analysisGate
|
await analysisGate
|
||||||
return aiResult
|
if (analysisFailure) throw new Error("Simulated AI failure")
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
const first = await service.credentials(1)
|
const first = await service.credentials(1)
|
||||||
const second = await service.credentials(2)
|
const second = await service.credentials(2)
|
||||||
@@ -265,6 +274,8 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
assert.equal(row.tenant, "1")
|
assert.equal(row.tenant, "1")
|
||||||
assert.equal(row.mimeType, "application/pdf")
|
assert.equal(row.mimeType, "application/pdf")
|
||||||
assert.equal(row.extracted_text, null)
|
assert.equal(row.extracted_text, null)
|
||||||
|
assert.equal((await pool.query("SELECT suggestion_status FROM scanner_inbox_documents WHERE file_id=$1", [row.id])).rows[0].suggestion_status, "pending")
|
||||||
|
assert.equal(analyses.length, 0, "the upload only queues preparation; it does not wait for OCR/AI")
|
||||||
assert.deepEqual(stored.get(row.path), pdf)
|
assert.deepEqual(stored.get(row.path), pdf)
|
||||||
assert.equal((await PDFDocument.load(stored.get(row.path)!)).getPageCount(), 2)
|
assert.equal((await PDFDocument.load(stored.get(row.path)!)).getPageCount(), 2)
|
||||||
const listing = await app.inject({ method: "GET", url: "/api/document-inbox" })
|
const listing = await app.inject({ method: "GET", url: "/api/document-inbox" })
|
||||||
@@ -332,17 +343,21 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
[targetFolder, targetType, foreignFolder, archivedFolder])
|
[targetFolder, targetType, foreignFolder, archivedFolder])
|
||||||
const fileInto = (id: string, folder: string | null = targetFolder, type: string = targetType, headers = {}) =>
|
const fileInto = (id: string, folder: string | null = targetFolder, type: string = targetType, headers = {}) =>
|
||||||
app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder, type }, headers })
|
app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder, type }, headers })
|
||||||
const suggest = (id: string, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${id}/suggestion`, headers })
|
const suggest = (id: string, headers = {}) => app.inject({ method: "GET", url: `/api/document-inbox/${id}/suggestion`, headers })
|
||||||
|
const queue = (id: string, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${id}/suggestion`, headers })
|
||||||
await t.test("AI analysis is tenant scoped, validates destinations and never files a document", async () => {
|
await t.test("AI analysis is tenant scoped, validates destinations and never files a document", async () => {
|
||||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||||
const before = (await pool.query("SELECT folder, type, extracted_text FROM files WHERE id=$1", [id])).rows[0]
|
const before = (await pool.query("SELECT folder, type, extracted_text FROM files WHERE id=$1", [id])).rows[0]
|
||||||
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Rechnung erkannt." } }
|
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Rechnung erkannt." } }
|
||||||
assert.equal((await suggest(id, { "x-test-tenant": "2" })).statusCode, 404)
|
assert.equal((await suggest(id, { "x-test-tenant": "2" })).statusCode, 404)
|
||||||
assert.equal((await suggest("invalid")).statusCode, 400)
|
assert.equal((await suggest("invalid")).statusCode, 400)
|
||||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||||
assert.equal((await suggest(id)).statusCode, 403)
|
assert.equal((await suggest(id)).statusCode, 403)
|
||||||
await pool.query("DELETE FROM auth_profiles")
|
await pool.query("DELETE FROM auth_profiles")
|
||||||
assert.equal(analyses.length, 0)
|
assert.equal(analyses.length, 0)
|
||||||
|
assert.equal((await suggest(id)).json().status, "pending")
|
||||||
|
assert.equal(analyses.length, 0, "reading the preview never starts analysis")
|
||||||
|
assert.equal(await service.processNextSuggestion(), true)
|
||||||
const result = await suggest(id, { "x-test-admin": "false" })
|
const result = await suggest(id, { "x-test-admin": "false" })
|
||||||
assert.equal(result.statusCode, 200, result.body)
|
assert.equal(result.statusCode, 200, result.body)
|
||||||
assert.deepEqual(result.json().suggestion, aiResult.suggestion)
|
assert.deepEqual(result.json().suggestion, aiResult.suggestion)
|
||||||
@@ -352,6 +367,8 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
assert.deepEqual((await pool.query("SELECT folder, type, extracted_text FROM files WHERE id=$1", [id])).rows[0], before)
|
assert.deepEqual((await pool.query("SELECT folder, type, extracted_text FROM files WHERE id=$1", [id])).rows[0], before)
|
||||||
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 1)
|
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 1)
|
||||||
aiResult = { suggestion: { ...aiResult.suggestion!, folderId: foreignFolder } }
|
aiResult = { suggestion: { ...aiResult.suggestion!, folderId: foreignFolder } }
|
||||||
|
assert.equal((await queue(id)).statusCode, 202)
|
||||||
|
await service.processNextSuggestion()
|
||||||
assert.equal((await suggest(id)).json().suggestion, null)
|
assert.equal((await suggest(id)).json().suggestion, null)
|
||||||
})
|
})
|
||||||
await t.test("AI results are checked again if destination availability changes during analysis", async () => {
|
await t.test("AI results are checked again if destination availability changes during analysis", async () => {
|
||||||
@@ -360,15 +377,93 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
let finish!: () => void
|
let finish!: () => void
|
||||||
analysisGate = new Promise(resolve => { finish = resolve })
|
analysisGate = new Promise(resolve => { finish = resolve })
|
||||||
const started = new Promise<void>(resolve => { analysisStarted = resolve })
|
const started = new Promise<void>(resolve => { analysisStarted = resolve })
|
||||||
const pending = suggest(id).then(response => response)
|
await queue(id)
|
||||||
|
const pending = service.processNextSuggestion()
|
||||||
await started
|
await started
|
||||||
await pool.query("UPDATE folders SET archived=true WHERE id=$1", [targetFolder])
|
await pool.query("UPDATE folders SET archived=true WHERE id=$1", [targetFolder])
|
||||||
finish()
|
finish()
|
||||||
assert.equal((await pending).json().suggestion, null)
|
await pending
|
||||||
|
assert.equal((await suggest(id)).json().suggestion, null)
|
||||||
await pool.query("UPDATE folders SET archived=false WHERE id=$1", [targetFolder])
|
await pool.query("UPDATE folders SET archived=false WHERE id=$1", [targetFolder])
|
||||||
analysisGate = undefined
|
analysisGate = undefined
|
||||||
analysisStarted = undefined
|
analysisStarted = undefined
|
||||||
})
|
})
|
||||||
|
await t.test("prepared results persist, failed jobs wait for explicit retry and expired claims resume", async () => {
|
||||||
|
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||||
|
const otherService = createScannerInboxService(db, undefined, async () => aiResult)
|
||||||
|
const before = analyses.length
|
||||||
|
assert.equal((await otherService.getSuggestion(1, id)).status, "ready")
|
||||||
|
assert.equal(analyses.length, before)
|
||||||
|
await queue(id)
|
||||||
|
analysisFailure = true
|
||||||
|
await service.processNextSuggestion()
|
||||||
|
analysisFailure = false
|
||||||
|
assert.equal((await suggest(id)).json().status, "failed")
|
||||||
|
assert.equal((await suggest(id)).json().suggestion, null)
|
||||||
|
const failedCount = analyses.length
|
||||||
|
await suggest(id)
|
||||||
|
assert.equal(analyses.length, failedCount)
|
||||||
|
await queue(id)
|
||||||
|
await pool.query("UPDATE scanner_inbox_documents SET suggestion_status='processing', suggestion_claim=$2, suggestion_updated_at=now()-interval '6 minutes' WHERE file_id=$1", [id, randomUUID()])
|
||||||
|
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Vorbereitet nach Neustart." } }
|
||||||
|
await otherService.processNextSuggestion()
|
||||||
|
assert.equal((await suggest(id)).json().suggestion.reason, "Vorbereitet nach Neustart.")
|
||||||
|
})
|
||||||
|
await t.test("an expired worker cannot overwrite a newer completed suggestion", async () => {
|
||||||
|
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||||
|
await queue(id)
|
||||||
|
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Alter Vorschlag." } }
|
||||||
|
let finish!: () => void
|
||||||
|
analysisGate = new Promise(resolve => { finish = resolve })
|
||||||
|
const started = new Promise<void>(resolve => { analysisStarted = resolve })
|
||||||
|
const pending = service.processNextSuggestion()
|
||||||
|
await started
|
||||||
|
await pool.query("UPDATE scanner_inbox_documents SET suggestion_updated_at=now()-interval '6 minutes' WHERE file_id=$1", [id])
|
||||||
|
const otherService = createScannerInboxService(db, undefined, async () => ({ suggestion: {
|
||||||
|
folderId: null, typeId: alternativeType, confidence: "medium", reason: "Neuer Vorschlag.",
|
||||||
|
} }))
|
||||||
|
await otherService.processNextSuggestion()
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal((await suggest(id)).json().suggestion.reason, "Neuer Vorschlag.")
|
||||||
|
analysisGate = undefined
|
||||||
|
analysisStarted = undefined
|
||||||
|
})
|
||||||
|
await t.test("entity choices and filing reject foreign, archived and wrong-type IDs atomically", async () => {
|
||||||
|
await pool.query("INSERT INTO projects VALUES (11, 1, 'Umbau', false), (12, 2, 'Fremdes Projekt', false), (13, 1, 'Archiv', true)")
|
||||||
|
await pool.query("INSERT INTO customers VALUES (21, 1, 'Musterkunde', false), (22, 2, 'Fremder Kunde', false)")
|
||||||
|
const checkId = randomUUID()
|
||||||
|
const profileId = randomUUID()
|
||||||
|
await pool.query("INSERT INTO checks VALUES ($1, 1, 'Prüfung', false)", [checkId])
|
||||||
|
await pool.query("INSERT INTO auth_profiles (id, tenant_id, full_name) VALUES ($1, 1, 'Test Mitarbeiter')", [profileId])
|
||||||
|
const choices = await app.inject({ method: "GET", url: "/api/document-inbox/assignment-options?kind=project&search=Umbau" })
|
||||||
|
assert.deepEqual(choices.json(), [{ id: "11", name: "Umbau" }])
|
||||||
|
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/assignment-options?kind=unknown" })).statusCode, 400)
|
||||||
|
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||||
|
for (const assignments of [{ project: "12" }, { project: "13" }, { project: randomUUID() }, { project: "11", customer: "22" }, { check: 11 }, { authProfile: randomUUID() }]) {
|
||||||
|
const res = await app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder: targetFolder, type: targetType, assignments } })
|
||||||
|
assert.equal(res.statusCode, 400, res.body)
|
||||||
|
}
|
||||||
|
assert.deepEqual((await pool.query("SELECT folder, type, project, customer FROM files WHERE id=$1", [id])).rows[0], { folder: null, type: null, project: null, customer: null })
|
||||||
|
await pool.query("UPDATE files SET project=11 WHERE id=$1", [id])
|
||||||
|
assert.deepEqual((await app.inject({ method: "GET", url: `/api/document-inbox/${id}/assignments` })).json(), [{ kind: "project", id: "11", name: "Umbau", available: true }])
|
||||||
|
assert.equal((await app.inject({ method: "GET", url: `/api/document-inbox/${id}/assignments`, headers: { "x-test-tenant": "2" } })).statusCode, 404)
|
||||||
|
await service.fileDocument(1, userId, id, targetFolder, targetType, { project: null, customer: "21", check: checkId, authProfile: profileId })
|
||||||
|
assert.deepEqual((await pool.query('SELECT project, customer, "check", auth_profile FROM files WHERE id=$1', [id])).rows[0], { project: null, customer: '21', check: checkId, auth_profile: profileId })
|
||||||
|
// Restore the fixture so existing retry/filing tests can exercise this same original document.
|
||||||
|
await pool.query('UPDATE files SET folder=null, type=null, customer=null, "check"=null, auth_profile=null WHERE id=$1', [id])
|
||||||
|
await pool.query("UPDATE scanner_inbox_documents SET filed_at=null WHERE file_id=$1", [id])
|
||||||
|
})
|
||||||
|
await t.test("menu count includes only this tenant's open, unarchived documents", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/document-inbox/count" })
|
||||||
|
assert.equal(res.json().total, (await service.list(1, 50, 0, "")).total)
|
||||||
|
const other = await app.inject({ method: "GET", url: "/api/document-inbox/count", headers: { "x-test-tenant": "2" } })
|
||||||
|
assert.equal(other.json().total, 1)
|
||||||
|
const id = (await service.list(1, 50, 0, "failed.pdf")).documents[0].id
|
||||||
|
await pool.query("UPDATE files SET archived=true WHERE id=$1", [id])
|
||||||
|
assert.equal((await service.openCount(1)).total, res.json().total - 1)
|
||||||
|
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
|
||||||
|
})
|
||||||
await t.test("filing options and destinations exclude other tenants and archived entries", async () => {
|
await t.test("filing options and destinations exclude other tenants and archived entries", async () => {
|
||||||
const options = await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })
|
const options = await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })
|
||||||
assert.equal(options.statusCode, 200)
|
assert.equal(options.statusCode, 200)
|
||||||
@@ -388,7 +483,7 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||||
assert.equal((await fileInto(id)).statusCode, 423)
|
assert.equal((await fileInto(id)).statusCode, 423)
|
||||||
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
||||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||||
assert.equal((await fileInto(id)).statusCode, 403)
|
assert.equal((await fileInto(id)).statusCode, 403)
|
||||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })).statusCode, 403)
|
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })).statusCode, 403)
|
||||||
await pool.query("DELETE FROM auth_profiles")
|
await pool.query("DELETE FROM auth_profiles")
|
||||||
@@ -434,6 +529,61 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
assert.equal((await fileInto(id)).statusCode, 404)
|
assert.equal((await fileInto(id)).statusCode, 404)
|
||||||
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
|
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
|
||||||
})
|
})
|
||||||
|
await t.test("archiving is tenant scoped, preserves the original and invalidates an ongoing analysis", async () => {
|
||||||
|
assert.equal((await send("archive.pdf")).statusCode, 201)
|
||||||
|
const id = (await service.list(1, 50, 0, "archive.pdf")).documents[0].id
|
||||||
|
const archive = (fileId = id, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${fileId}/archive`, headers })
|
||||||
|
assert.equal((await archive(id, { "x-test-tenant": "2" })).statusCode, 404)
|
||||||
|
assert.equal((await archive(randomUUID())).statusCode, 404)
|
||||||
|
assert.equal((await archive("invalid")).statusCode, 400)
|
||||||
|
const filedId = (await pool.query("SELECT file_id FROM scanner_inbox_documents WHERE tenant_id=1 AND remote_filename='scan.pdf'")).rows[0].file_id
|
||||||
|
assert.equal((await archive(filedId)).statusCode, 409)
|
||||||
|
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||||
|
assert.equal((await archive()).statusCode, 423)
|
||||||
|
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
||||||
|
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||||
|
assert.equal((await archive()).statusCode, 403)
|
||||||
|
await pool.query("DELETE FROM auth_profiles")
|
||||||
|
const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
|
||||||
|
const countBefore = (await service.openCount(1)).total
|
||||||
|
const storedBefore = stored.size
|
||||||
|
// Run this document first, then archive it while its provider response is still pending.
|
||||||
|
await pool.query("UPDATE scanner_inbox_documents SET received_at=now()-interval '1 day' WHERE file_id=$1", [id])
|
||||||
|
let finish!: () => void
|
||||||
|
analysisGate = new Promise(resolve => { finish = resolve })
|
||||||
|
const started = new Promise<void>(resolve => { analysisStarted = resolve })
|
||||||
|
const pending = service.processNextSuggestion()
|
||||||
|
await started
|
||||||
|
assert.equal(analyses.at(-1).document.name, "archive.pdf")
|
||||||
|
const result = await archive(id, { "x-test-admin": "false" })
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
analysisGate = undefined
|
||||||
|
analysisStarted = undefined
|
||||||
|
assert.equal(result.statusCode, 200, result.body)
|
||||||
|
assert.deepEqual(result.json(), { id, archived: true })
|
||||||
|
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
|
||||||
|
assert.equal(row.archived, true)
|
||||||
|
assert.equal(row.updated_by, userId)
|
||||||
|
assert.ok(row.updated_at)
|
||||||
|
assert.equal(row.path, before.path)
|
||||||
|
assert.equal(row.folder, before.folder)
|
||||||
|
assert.equal(row.type, before.type)
|
||||||
|
assert.deepEqual(stored.get(row.path), pdf)
|
||||||
|
const receipt = (await pool.query("SELECT filed_at, suggestion_claim, suggestion_status FROM scanner_inbox_documents WHERE file_id=$1", [id])).rows[0]
|
||||||
|
assert.deepEqual(receipt, { filed_at: null, suggestion_claim: null, suggestion_status: "pending" })
|
||||||
|
assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0)
|
||||||
|
assert.equal((await service.openCount(1)).total, countBefore - 1)
|
||||||
|
assert.equal((await suggest(id)).statusCode, 404)
|
||||||
|
assert.equal((await fileInto(id)).statusCode, 404)
|
||||||
|
assert.equal((await archive()).statusCode, 200, "repeated requests remain successful")
|
||||||
|
assert.equal((await pool.query("SELECT updated_at FROM files WHERE id=$1", [id])).rows[0].updated_at.getTime(), row.updated_at.getTime())
|
||||||
|
assert.equal((await send("archive.pdf")).statusCode, 204)
|
||||||
|
assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0)
|
||||||
|
assert.equal(stored.size, storedBefore)
|
||||||
|
while (await service.processNextSuggestion()) { /* drain the remaining fixtures */ }
|
||||||
|
assert.equal(analyses.filter(item => item.document.name === "archive.pdf").length, 1)
|
||||||
|
})
|
||||||
await t.test("maintenance, portal access and administrator-only settings are enforced", async () => {
|
await t.test("maintenance, portal access and administrator-only settings are enforced", async () => {
|
||||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||||
assert.equal((await send("maintenance.pdf")).statusCode, 423)
|
assert.equal((await send("maintenance.pdf")).statusCode, 423)
|
||||||
@@ -444,7 +594,7 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
assert.equal((await app.inject({ method, url, headers: { "x-test-admin": "false" } })).statusCode, 403)
|
assert.equal((await app.inject({ method, url, headers: { "x-test-admin": "false" } })).statusCode, 403)
|
||||||
}
|
}
|
||||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-admin": "false" } })).statusCode, 200)
|
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-admin": "false" } })).statusCode, 200)
|
||||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox" })).statusCode, 403)
|
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox" })).statusCode, 403)
|
||||||
await pool.query("DELETE FROM auth_profiles")
|
await pool.query("DELETE FROM auth_profiles")
|
||||||
})
|
})
|
||||||
@@ -463,4 +613,64 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
|
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
|
||||||
assert.equal((await service.list(1, 50, 0, "")).total, 2)
|
assert.equal((await service.list(1, 50, 0, "")).total, 2)
|
||||||
})
|
})
|
||||||
|
await t.test("MCP uploads, downloads and filing use the same tenant-scoped document lifecycle", async () => {
|
||||||
|
const tools = createDocumentTools({ inbox: () => service, readFile: async key => {
|
||||||
|
assert.ok(stored.has(key))
|
||||||
|
return stored.get(key)!
|
||||||
|
} })
|
||||||
|
const call = async (name: string, args: any = {}, tenantId = 1) => {
|
||||||
|
const tool = tools.find(tool => tool.name === `documents.${name}`)!
|
||||||
|
const context = { server: { db }, userId, tenantId, isAdmin: false, permissions: ["documents.read", "documents.write"] } as any
|
||||||
|
assertToolPermission(context, tool)
|
||||||
|
return await tool.handler(context, args) as any
|
||||||
|
}
|
||||||
|
const upload = (filename: string, body = pdf, tenant = 1) => call("files.upload", { filename, contentBase64: body.toString("base64") }, tenant)
|
||||||
|
await service.disable(1)
|
||||||
|
const created = await upload("mcp.pdf")
|
||||||
|
assert.equal(created.created, true, "MCP uploads do not require scanner credentials")
|
||||||
|
assert.equal((await upload("mcp.pdf")).id, created.id)
|
||||||
|
assert.equal((await upload("mcp.pdf")).created, false)
|
||||||
|
await assert.rejects(upload("mcp.pdf", samplePdf), { statusCode: 409 })
|
||||||
|
assert.notEqual((await upload("mcp.pdf", pdf, 2)).id, created.id)
|
||||||
|
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [created.id])).rows[0]
|
||||||
|
assert.equal(row.created_by, userId)
|
||||||
|
assert.deepEqual(stored.get(row.path), pdf)
|
||||||
|
assert.equal(row.extracted_text, null)
|
||||||
|
const list = await call("inbox.list", { search: "mcp.pdf", limit: 1 })
|
||||||
|
assert.equal(list.rows[0].id, created.id)
|
||||||
|
assert.equal(list.rows[0].suggestionStatus, "pending")
|
||||||
|
assert.equal((await call("inbox.suggestion.get", { id: created.id })).status, "pending")
|
||||||
|
const downloaded = await call("files.download", { id: created.id, format: "base64" })
|
||||||
|
assert.deepEqual(Buffer.from(downloaded.contentBase64, "base64"), pdf)
|
||||||
|
await assert.rejects(call("files.download", { id: created.id }, 2), { statusCode: 404 })
|
||||||
|
await assert.rejects(call("inbox.archive", { id: created.id }, 2), { statusCode: 404 })
|
||||||
|
await assert.rejects(call("inbox.file", { id: created.id, folder: foreignFolder, type: targetType }), { statusCode: 400 })
|
||||||
|
await assert.rejects(call("inbox.file", { id: created.id, folder: targetFolder, type: alternativeType }), { statusCode: 400 })
|
||||||
|
await pool.query("INSERT INTO projects (id, tenant, name) VALUES (901, 1, 'MCP project'), (902, 2, 'Foreign MCP project')")
|
||||||
|
await assert.rejects(call("inbox.file", { id: created.id, folder: targetFolder, type: targetType, assignments: { project: 902 } }), { statusCode: 400 })
|
||||||
|
const filed = await call("inbox.file", { id: created.id, folder: targetFolder, type: targetType, assignments: { project: 901 } })
|
||||||
|
assert.equal(filed.id, created.id)
|
||||||
|
assert.equal((await call("inbox.list", { search: "mcp.pdf" })).rows.length, 0)
|
||||||
|
assert.equal((await call("files.list", { project: 901 })).rows[0].id, created.id)
|
||||||
|
assert.equal((await call("files.get", { id: created.id })).file.path, undefined)
|
||||||
|
await assert.rejects(call("inbox.archive", { id: created.id }), { statusCode: 409 })
|
||||||
|
const archived = await upload("mcp-archive.pdf")
|
||||||
|
await call("inbox.archive", { id: archived.id })
|
||||||
|
await assert.rejects(call("files.download", { id: archived.id }), { statusCode: 404 })
|
||||||
|
assert.equal((await call("files.list", { search: "mcp-archive.pdf" })).rows.length, 0)
|
||||||
|
const before = stored.size
|
||||||
|
storageFailure = true
|
||||||
|
await assert.rejects(upload("mcp-storage-error.pdf"), /Simulated object store failure/)
|
||||||
|
storageFailure = false
|
||||||
|
await assert.rejects(upload("reject.pdf"))
|
||||||
|
assert.equal(stored.size, before)
|
||||||
|
assert.equal((await pool.query("SELECT count(*) FROM files WHERE name IN ('mcp-storage-error.pdf', 'reject.pdf')")).rows[0].count, "0")
|
||||||
|
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||||
|
await assert.rejects(upload("mcp-maintenance.pdf"), { statusCode: 423 })
|
||||||
|
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
||||||
|
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||||
|
await assert.rejects(upload("mcp-portal.pdf"), { statusCode: 403 })
|
||||||
|
await assert.rejects(call("files.download", { id: created.id }), { statusCode: 403 })
|
||||||
|
await pool.query("DELETE FROM auth_profiles")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
105
backend/tests/serialExecutionCancellation.test.ts
Normal file
105
backend/tests/serialExecutionCancellation.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { PgDialect } from 'drizzle-orm/pg-core'
|
||||||
|
import * as schema from '../db/schema'
|
||||||
|
import { cancelManualGeneration, finishManualGeneration } from '../src/modules/serialexecution.service'
|
||||||
|
|
||||||
|
const id = '00000000-0000-4000-8000-000000000001'
|
||||||
|
const dialect = new PgDialect()
|
||||||
|
|
||||||
|
function fixture(status: string | null, options: { cancelDuringFinish?: boolean } = {}) {
|
||||||
|
const execution = status ? { id, tenant: 7, status } : null
|
||||||
|
const calls: any[] = []
|
||||||
|
const db: any = {
|
||||||
|
transaction: async (fn: any) => fn(db),
|
||||||
|
select: () => query('select'),
|
||||||
|
update: (table: any) => query('update', table),
|
||||||
|
}
|
||||||
|
function query(kind: string, table?: any) {
|
||||||
|
let values: any
|
||||||
|
let condition: any
|
||||||
|
const builder: any = {
|
||||||
|
from: (value: any) => { table = value; return builder },
|
||||||
|
set: (value: any) => { values = value; return builder },
|
||||||
|
where: (value: any) => { condition = value; return builder },
|
||||||
|
limit: () => builder,
|
||||||
|
for: (lock: string) => { assert.equal(lock, 'update'); return builder },
|
||||||
|
returning: () => builder,
|
||||||
|
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
|
||||||
|
const sql = condition ? dialect.sqlToQuery(condition) : null
|
||||||
|
calls.push({ kind, table, values, sql })
|
||||||
|
if (table === schema.serialExecutions) {
|
||||||
|
if (!execution || !sql.params.includes(7)) return []
|
||||||
|
if (values?.status === 'processing' && !['draft', 'error'].includes(execution.status)) return []
|
||||||
|
if (values) Object.assign(execution, values)
|
||||||
|
return [{ ...execution }]
|
||||||
|
}
|
||||||
|
if (table === schema.createddocuments && kind === 'select' && options.cancelDuringFinish) {
|
||||||
|
execution!.status = 'cancelling'
|
||||||
|
return [{ id: 123, state: 'Entwurf' }]
|
||||||
|
}
|
||||||
|
if (table === schema.tenants) return [{ id: 7 }]
|
||||||
|
return []
|
||||||
|
}).then(resolve, reject),
|
||||||
|
}
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
return { server: { db } as any, execution, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cancels drafts and archives only unarchived draft invoices of the same tenant and run', async () => {
|
||||||
|
const f = fixture('draft')
|
||||||
|
const result = await cancelManualGeneration(f.server, id, 7)
|
||||||
|
assert.equal(result.execution.status, 'cancelled')
|
||||||
|
const archive = f.calls.find(c => c.table === schema.createddocuments)
|
||||||
|
assert.deepEqual(archive.values, { archived: true })
|
||||||
|
assert.deepEqual(archive.sql.params, [id, 7, 'Entwurf', false])
|
||||||
|
for (const column of ['serialexecution', 'tenant', 'state', 'archived']) {
|
||||||
|
assert.ok(archive.sql.sql.includes(`"${column}"`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const status of ['generating', 'processing']) {
|
||||||
|
test(`requests cooperative cancellation while ${status}`, async () => {
|
||||||
|
const f = fixture(status)
|
||||||
|
const result = await cancelManualGeneration(f.server, id, 7)
|
||||||
|
assert.equal(result.execution.status, 'cancelling')
|
||||||
|
assert.equal(f.calls.some(c => c.table === schema.createddocuments), false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of ['cancelled', 'cancelling']) {
|
||||||
|
test(`repeated cancellation of ${status} is idempotent`, async () => {
|
||||||
|
const f = fixture(status)
|
||||||
|
await cancelManualGeneration(f.server, id, 7)
|
||||||
|
assert.equal(f.calls.some(c => c.kind === 'update'), false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects completed runs and missing or foreign-tenant executions without writes', async () => {
|
||||||
|
for (const [status, tenant, code] of [['completed', 7, 409], [null, 7, 404], ['draft', 8, 404]] as const) {
|
||||||
|
const f = fixture(status)
|
||||||
|
await assert.rejects(cancelManualGeneration(f.server, id, tenant), { statusCode: code })
|
||||||
|
assert.equal(f.calls.some(c => c.kind === 'update'), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cannot finalize cancelled, cancelling, completed or already processing runs', async () => {
|
||||||
|
for (const status of ['cancelled', 'cancelling', 'completed', 'processing', 'generating']) {
|
||||||
|
const f = fixture(status)
|
||||||
|
await assert.rejects(finishManualGeneration(f.server, id, 7), { statusCode: 409 })
|
||||||
|
assert.equal(f.execution!.status, status)
|
||||||
|
assert.equal(f.calls.length, 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancellation during finalization skips remaining invoices and settles as cancelled', async () => {
|
||||||
|
const f = fixture('draft', { cancelDuringFinish: true })
|
||||||
|
const result = await finishManualGeneration(f.server, id, 7)
|
||||||
|
assert.equal(result.execution.status, 'cancelled')
|
||||||
|
assert.equal(result.processed, 0)
|
||||||
|
assert.equal(result.success, false)
|
||||||
|
const writes = f.calls.filter(c => c.kind === 'update' && c.table === schema.createddocuments)
|
||||||
|
assert.equal(writes.length, 1)
|
||||||
|
assert.deepEqual(writes[0].values, { archived: true })
|
||||||
|
})
|
||||||
204
backend/tests/timeV2.test.ts
Normal file
204
backend/tests/timeV2.test.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { normalizeSpan, evaluate, range, transition, overlaps } from '../src/modules/time-v2/domain';
|
||||||
|
import { TimeService, authorize } from '../src/modules/time-v2/service';
|
||||||
|
import { TimeMigration, previewLegacy } from '../src/modules/time-v2/migration';
|
||||||
|
import { legacySpans, installTimeCompatibility, legacyEvent } from '../src/modules/time-v2/compatibility';
|
||||||
|
import timeV2Routes from '../src/routes/staff/time-v2';
|
||||||
|
const at = (day: string, time: string) => `${day}T${time}:00Z`;
|
||||||
|
const work = (start = '2026-10-01T06:00:00Z', end: string | null = '2026-10-01T14:30:00Z') => normalizeSpan({ type: 'work', started_at: start, ended_at: end,
|
||||||
|
breaks: end ? [{ started_at: '2026-10-01T10:00:00Z', ended_at: '2026-10-01T10:30:00Z' }] : [] });
|
||||||
|
const profile = { weekly_regular_working_hours: { 1: 8, 2: 8, 3: 8, 4: 8, 5: 8 }, recreation_days_compensation: true };
|
||||||
|
test('attendance subtracts breaks and clips both at month boundaries', () => {
|
||||||
|
const s = work();
|
||||||
|
s.status = 'approved';
|
||||||
|
const summary = evaluate([s], profile, [], '2026-10-01', '2026-10-01');
|
||||||
|
assert.equal(summary.presenceMinutes, 510);
|
||||||
|
assert.equal(summary.breakMinutes, 30);
|
||||||
|
assert.equal(summary.sumWorkingMinutesApproved, 480);
|
||||||
|
assert.equal(summary.saldoApproved, 0);
|
||||||
|
const night = normalizeSpan({ type: 'work', started_at: '2026-09-30T21:00:00Z', ended_at: '2026-10-01T02:00:00Z', breaks: [{ started_at: '2026-09-30T21:45:00Z', ended_at: '2026-09-30T22:15:00Z' }] });
|
||||||
|
night.status = 'approved';
|
||||||
|
assert.equal(evaluate([night], profile, [], '2026-10-01', '2026-10-01').sumWorkingMinutesApproved, 225);
|
||||||
|
});
|
||||||
|
test('DST uses elapsed instants; all-day absence uses working days and avoids holiday double credit', () => {
|
||||||
|
const dst = normalizeSpan({ type: 'work', started_at: '2026-10-25T00:00:00Z', ended_at: '2026-10-25T03:00:00Z' });
|
||||||
|
dst.status = 'approved';
|
||||||
|
assert.equal(evaluate([dst], profile, [], '2026-10-25', '2026-10-25').sumWorkingMinutesApproved, 180);
|
||||||
|
const vacation = normalizeSpan({ type: 'vacation', date_from: '2026-10-01', date_to: '2026-10-06' });
|
||||||
|
vacation.status = 'approved';
|
||||||
|
const r = evaluate([vacation], profile, ['2026-10-02', '2026-10-02'], '2026-10-01', '2026-10-05');
|
||||||
|
assert.equal(r.sumVacationDays, 2);
|
||||||
|
assert.equal(r.sumWorkingMinutesVacationDays, 960);
|
||||||
|
assert.equal(r.sumWorkingMinutesRecreationDays, 480);
|
||||||
|
assert.equal(r.saldoApproved, 0);
|
||||||
|
const comp = normalizeSpan({ type: 'overtime_compensation', date_from: '2026-10-01', date_to: '2026-10-02' });
|
||||||
|
comp.status = 'approved';
|
||||||
|
assert.equal(evaluate([comp], profile, [], '2026-10-01', '2026-10-01').saldoApproved, -480);
|
||||||
|
assert.throws(() => range('2026-02-30', '2026-03-01'));
|
||||||
|
});
|
||||||
|
test('reject invalid intervals, pause collisions and projects inside breaks; allow adjacent attendances', () => {
|
||||||
|
assert.throws(() => normalizeSpan({ type: 'work', started_at: at('2026-10-01', '08:00'), ended_at: at('2026-10-01', '07:00') }));
|
||||||
|
const s = work();
|
||||||
|
assert.throws(() => normalizeSpan({ ...s, breaks: [...s.breaks, { started_at: '2026-10-01T10:15:00Z', ended_at: '2026-10-01T11:00:00Z' }] }));
|
||||||
|
assert.throws(() => normalizeSpan({ ...s, projects: [{ project_id: 1, started_at: '2026-10-01T09:00:00Z', ended_at: '2026-10-01T11:00:00Z' }] }));
|
||||||
|
assert.equal(overlaps(s, { started_at: s.ended_at!, ended_at: null }), false);
|
||||||
|
assert.throws(() => transition(work(undefined, null), 'submit'));
|
||||||
|
assert.throws(() => transition(s, 'approve'));
|
||||||
|
s.status = 'submitted';
|
||||||
|
assert.throws(() => transition(s, 'reject', ''));
|
||||||
|
assert.equal(transition(s, 'reject', 'Korrektur'), 'rejected');
|
||||||
|
});
|
||||||
|
test('migration preserves the overtime type and flags missing boundaries, mixed approvals and duplicate models', () => {
|
||||||
|
const user = randomUUID();
|
||||||
|
const events = [{ id: 'a', eventtype: 'overtime_compensation_start', eventtime: at('2026-10-01', '06:00') }, { id: 'b', eventtype: 'overtime_compensation_end', eventtime: at('2026-10-01', '14:00') }];
|
||||||
|
let p = previewLegacy(events, [], []);
|
||||||
|
assert.equal(p.issues.length, 0);
|
||||||
|
assert.equal(p.candidates[0].span.type, 'overtime_compensation');
|
||||||
|
p = previewLegacy(events.slice(0, 1), [], []);
|
||||||
|
assert.match(p.issues.join(' '), /Offener/);
|
||||||
|
p = previewLegacy([...events, { id: 'c', eventtype: 'approved', related_event_id: 'a', created_at: at('2026-10-02', '08:00') }], [], []);
|
||||||
|
assert.match(p.issues.join(' '), /Uneinheitliche/);
|
||||||
|
p = previewLegacy(events, [{ id: 'd', user_id: user, type: 'work', started_at: events[0].eventtime, stopped_at: events[1].eventtime, state: 'draft' }], []);
|
||||||
|
assert.match(p.issues.join(' '), /Überlappende/);
|
||||||
|
});
|
||||||
|
test('own access and role separation', () => {
|
||||||
|
const a = { tenant: 1, user: 'a' };
|
||||||
|
assert.doesNotThrow(() => authorize(a, 'a', 'write'));
|
||||||
|
assert.throws(() => authorize(a, 'b', 'read'));
|
||||||
|
assert.throws(() => authorize(a, 'a', 'approve'));
|
||||||
|
assert.doesNotThrow(() => authorize({ ...a, permissions: ['staff.time.approve'] }, 'b', 'read'));
|
||||||
|
assert.throws(() => authorize({ ...a, permissions: ['staff.time.read_all'] }, 'b', 'write'));
|
||||||
|
});
|
||||||
|
const databaseUrl = process.env.TIME_V2_TEST_DATABASE_URL;
|
||||||
|
async function fixture() {
|
||||||
|
if (!databaseUrl || new URL(databaseUrl).pathname !== '/time_v2_test')
|
||||||
|
throw new Error('Use an explicitly designated /time_v2_test database.');
|
||||||
|
const schema = `time_v2_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
const admin = new Pool({ connectionString: databaseUrl, max: 1 });
|
||||||
|
await admin.query(`CREATE SCHEMA ${schema}`);
|
||||||
|
const pool = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}`, max: 8 });
|
||||||
|
const db = drizzle(pool);
|
||||||
|
try {
|
||||||
|
await pool.query(`CREATE TABLE tenants(id bigint PRIMARY KEY);
|
||||||
|
CREATE TABLE auth_users(id uuid PRIMARY KEY);
|
||||||
|
CREATE TABLE auth_profiles(user_id uuid,tenant_id bigint,full_name text,weekly_regular_working_hours jsonb,state_code text,recreation_days_compensation boolean);
|
||||||
|
CREATE TABLE projects(id bigint PRIMARY KEY,tenant bigint,name text,archived boolean DEFAULT false);
|
||||||
|
CREATE TABLE holidays(date date,state_code text);
|
||||||
|
CREATE TABLE staff_time_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id bigint,user_id uuid,event_type text,event_time timestamptz,created_at timestamptz DEFAULT now(),invalidates_event_id uuid,related_event_id uuid,metadata jsonb);
|
||||||
|
CREATE TABLE staff_time_entries(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id bigint,user_id uuid,type text,started_at timestamptz,stopped_at timestamptz,state text,description text,archived boolean DEFAULT false);
|
||||||
|
CREATE TABLE staff_time_entry_connects(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),time_entry_id uuid,project_id bigint,started_at timestamptz,stopped_at timestamptz,notes text);`);
|
||||||
|
await pool.query(await readFile(new URL('../db/migrations/0080_staff_time_spans.sql', import.meta.url), 'utf8'));
|
||||||
|
const user = randomUUID(), other = randomUUID(), outsider = randomUUID();
|
||||||
|
await pool.query('INSERT INTO tenants VALUES (1),(2)');
|
||||||
|
await pool.query('INSERT INTO auth_users VALUES ($1),($2),($3)', [user, other, outsider]);
|
||||||
|
await pool.query(`INSERT INTO auth_profiles VALUES ($1,1,'Anna',$4,'DE-NI',true),($2,1,'Ben',$4,'DE-NI',true),($3,2,'Outside',$4,'DE-NI',true)`, [user, other, outsider, JSON.stringify(profile.weekly_regular_working_hours)]);
|
||||||
|
await pool.query("INSERT INTO projects VALUES (1,1,'Project',false),(2,2,'Other',false)");
|
||||||
|
const service = new TimeService(db), migration = new TimeMigration(service), actor = { tenant: 1, user, admin: true };
|
||||||
|
return { pool, db, user, other, outsider, service, migration, actor, close: async () => { await pool.end(); await admin.query(`DROP SCHEMA ${schema} CASCADE`); await admin.end(); } };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
await pool.end();
|
||||||
|
await admin.query(`DROP SCHEMA ${schema} CASCADE`);
|
||||||
|
await admin.end();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test('PostgreSQL: atomic adoption, idempotence, concurrency, workflow and tenant isolation', { skip: !databaseUrl }, async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
const { service: s, migration: m, actor: a, user, pool } = f;
|
||||||
|
try {
|
||||||
|
await pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES (1,$1,'work_start','2026-09-01T06:00:00Z'),(1,$1,'pause_start','2026-09-01T10:00:00Z'),(1,$1,'pause_end','2026-09-01T10:30:00Z'),(1,$1,'work_end','2026-09-01T14:30:00Z')`, [user]);
|
||||||
|
const preview = await m.preview(a, user);
|
||||||
|
assert.equal(preview.count, 1);
|
||||||
|
assert.equal(preview.totalMinutes, 480);
|
||||||
|
await assert.rejects(m.activate(a, user, 'stale'), /Vorschau/);
|
||||||
|
await m.activate(a, user, preview.fingerprint);
|
||||||
|
assert.equal((await s.list(a, user)).length, 1);
|
||||||
|
assert.equal((await m.activate(a, user, preview.fingerprint)).alreadyActivated, true);
|
||||||
|
await assert.rejects(pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES(1,$1,'work_start',now())`, [user]), /2.0/);
|
||||||
|
const key = randomUUID(), cmd = { action: 'start', at: '2026-10-01T06:00:00Z' };
|
||||||
|
const [one, two] = await Promise.all([s.mutate(a, user, key, cmd), s.mutate(a, user, key, cmd)]);
|
||||||
|
assert.equal(one.id, two.id);
|
||||||
|
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'create', id: one.id, version: one.version, data: work() }), /bestehende ID/);
|
||||||
|
const concurrent = await Promise.allSettled([s.mutate(a, user, randomUUID(), cmd), s.mutate(a, user, randomUUID(), cmd)]);
|
||||||
|
assert.equal(concurrent.filter(r => r.status === 'fulfilled').length, 0);
|
||||||
|
await assert.rejects(s.mutate(a, user, key, { ...cmd, at: '2026-10-01T07:00:00Z' }), /Anfrage-ID/);
|
||||||
|
await assert.rejects(s.mutate(a, user, randomUUID(), cmd), /überschneidet/);
|
||||||
|
let item = await s.mutate(a, user, randomUUID(), { action: 'pause', id: one.id, version: one.version, at: '2026-10-01T10:00:00Z' });
|
||||||
|
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'stop', id: one.id, version: one.version, at: '2026-10-01T10:30:00Z' }), /zwischenzeitlich/);
|
||||||
|
item = await s.mutate(a, user, randomUUID(), { action: 'stop', id: item.id, version: item.version, at: '2026-10-01T10:30:00Z' });
|
||||||
|
assert.equal(item.breaks[0].ended_at, item.ended_at);
|
||||||
|
assert.equal((await s.evaluation(a, user, '2026-10-01', '2026-10-01')).summary.presenceMinutes, 270);
|
||||||
|
await assert.rejects(s.mutate({ ...a, admin: false }, user, randomUUID(), { action: 'approve', id: item.id, version: item.version }), /Berechtigung/);
|
||||||
|
item = await s.mutate(a, user, randomUUID(), { action: 'submit', id: item.id, version: item.version });
|
||||||
|
item = await s.mutate(a, user, randomUUID(), { action: 'approve', id: item.id, version: item.version });
|
||||||
|
assert.equal((await s.evaluation(a, user, '2026-10-01', '2026-10-01')).summary.sumWorkingMinutesApproved, 240);
|
||||||
|
const changed = await s.mutate(a, user, randomUUID(), { action: 'update', id: item.id, version: item.version, reason: 'Notiz ergänzt', data: { ...item, description: 'Neu' } });
|
||||||
|
assert.equal(changed.status, 'factual');
|
||||||
|
assert.equal((await s.history(a, item.id)).length, 6);
|
||||||
|
await assert.rejects(s.get({ ...a, tenant: 2, user: f.outsider }, item.id), /nicht gefunden/);
|
||||||
|
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'update', id: changed.id, version: changed.version, reason: 'Projekt', data: { ...changed, projects: [{ project_id: 2, started_at: '2026-10-01T06:00:00Z', ended_at: '2026-10-01T07:00:00Z' }] } }), /Mandanten/);
|
||||||
|
assert.equal((await s.get(a, item.id)).version, changed.version);
|
||||||
|
const compat = legacySpans([changed]);
|
||||||
|
assert.equal(compat.length, 2);
|
||||||
|
assert.equal(compat[0].endedAt, '2026-10-01T10:00:00.000Z');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await f.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('HTTP routes: validation, permissions, compatibility and migration conflicts', { skip: !databaseUrl }, async () => {
|
||||||
|
const f = await fixture(), app = Fastify();
|
||||||
|
app.decorate('db', f.db as any);
|
||||||
|
app.addHook('preHandler', async (req) => { req.user = { user_id: f.user, tenant_id: 1, email: 'test@example.test', is_admin: req.headers['x-admin'] === 'yes' }; req.permissions = []; });
|
||||||
|
await app.register(timeV2Routes, { prefix: '/api' });
|
||||||
|
await app.register(async (legacy) => { installTimeCompatibility(legacy); legacy.get('/staff/time/spans', async () => []); legacy.post('/staff/time/event', async () => ({ old: true })); }, { prefix: '/api' });
|
||||||
|
try {
|
||||||
|
assert.equal((await app.inject('/api/v2/time/spans?user_id=bad')).statusCode, 400);
|
||||||
|
assert.equal((await app.inject(`/api/v2/time/spans?user_id=${f.other}&from=2026-10-01&to=2026-10-02`)).statusCode, 403);
|
||||||
|
assert.equal((await app.inject('/api/v2/time/migration')).statusCode, 403);
|
||||||
|
const preview = (await app.inject({ url: '/api/v2/time/migration', headers: { 'x-admin': 'yes' } })).json();
|
||||||
|
assert.equal((await app.inject({ method: 'POST', url: '/api/v2/time/activate', headers: { 'x-admin': 'yes' }, payload: { fingerprint: preview.fingerprint } })).statusCode, 200);
|
||||||
|
const body = { eventtype: 'work_start', eventtime: '2026-10-01T06:00:00Z', user_id: f.user };
|
||||||
|
const created = await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: body });
|
||||||
|
assert.equal(created.statusCode, 200, created.body);
|
||||||
|
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: body })).json().id, created.json().id);
|
||||||
|
assert.equal((await app.inject('/api/staff/time/spans?from=2026-09-01&to=2026-09-02')).json().length, 1, 'Running attendance survives a different selected month');
|
||||||
|
const stop = { ...body, eventtype: 'work_end', eventtime: '2026-10-01T08:00:00Z' };
|
||||||
|
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: stop })).statusCode, 200);
|
||||||
|
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: stop })).statusCode, 200);
|
||||||
|
const spans = (await app.inject('/api/staff/time/spans')).json();
|
||||||
|
assert.equal(spans.length, 1);
|
||||||
|
assert.equal(spans[0].model, 'spans-v2');
|
||||||
|
await f.pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES(1,$1,'work_start',now())`, [f.other]);
|
||||||
|
const p = await f.migration.preview(f.actor, f.other);
|
||||||
|
assert.ok(p.issues.length);
|
||||||
|
await assert.rejects(f.migration.activate(f.actor, f.other, p.fingerprint), /Konflikte/);
|
||||||
|
assert.equal(await f.service.active(f.actor, f.other), null);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await app.close();
|
||||||
|
await f.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('legacy compatibility keeps paused attendance on both work and pause segments', () => {
|
||||||
|
const s = work(undefined, null); s.id = randomUUID(); s.user_id = randomUUID();
|
||||||
|
s.breaks = [{ started_at: '2026-10-01T10:00:00Z', ended_at: null }];
|
||||||
|
const segments = legacySpans([s]);
|
||||||
|
assert.equal(segments.length, 2);
|
||||||
|
assert.equal(segments[0].type, 'work');
|
||||||
|
assert.equal(segments[0].endedAt, s.breaks[0].started_at);
|
||||||
|
assert.equal(segments[1].type, 'pause');
|
||||||
|
assert.equal(segments[1].endedAt, null);
|
||||||
|
for (const segment of segments) {
|
||||||
|
assert.equal(segment.model, 'spans-v2');
|
||||||
|
assert.equal(segment.id, s.id);
|
||||||
|
assert.deepEqual(segment.attendance, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -125,7 +125,7 @@ services:
|
|||||||
API_BASE_URL: ${API_BASE_URL}
|
API_BASE_URL: ${API_BASE_URL}
|
||||||
FEDEO_PUBLIC_URL: ${FEDEO_PUBLIC_URL:-}
|
FEDEO_PUBLIC_URL: ${FEDEO_PUBLIC_URL:-}
|
||||||
FEDEO_FEDERATION_URL: ${FEDEO_FEDERATION_URL:-}
|
FEDEO_FEDERATION_URL: ${FEDEO_FEDERATION_URL:-}
|
||||||
FEDEO_FEDERATION_ENABLED: ${FEDEO_FEDERATION_ENABLED:-false}
|
FEDEO_FEDERATION_ENABLED: ${FEDEO_FEDERATION_ENABLED:-auto}
|
||||||
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
|
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
|
||||||
GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID}
|
GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID}
|
||||||
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}
|
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
|||||||
- [Konzept zum Austausch von MinIO](./minio-austausch-konzept.md)
|
- [Konzept zum Austausch von MinIO](./minio-austausch-konzept.md)
|
||||||
- [MinIO-Umstellung: ausführbarer Pilot](./minio-umstellung-pilot.md)
|
- [MinIO-Umstellung: ausführbarer Pilot](./minio-umstellung-pilot.md)
|
||||||
- [Konzept für föderierte Chats und externe Projektteilnehmer](./chat-foederation-konzept.md)
|
- [Konzept für föderierte Chats und externe Projektteilnehmer](./chat-foederation-konzept.md)
|
||||||
|
|
||||||
|
- [Zeiterfassung 2.0 und Datenübernahme](./zeiterfassung-2.0.md)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Konzept: Föderierte Chats und externe Projektteilnehmer
|
# Konzept: Föderierte Chats und externe Projektteilnehmer
|
||||||
|
|
||||||
Stand: 30. September 2026. Das ursprüngliche Zielbild basiert auf Commit `df48c976`. Die Schritte 0, 1a, 1b und ein föderierter Web-Pilot aus Schritt 2 sind inzwischen implementiert (`21fe4723`, `ea641bb4`, `7b5696ed`). Die folgenden Architekturkapitel enthalten weiterhin spätere Ausbaustufen; den tatsächlichen Lieferumfang, die Einrichtung und verbleibende Grenzen beschreibt Abschnitt 15. Es wurden keine produktiven Instanzen verändert oder echten Einladungen versendet.
|
Stand: 2. Oktober 2026. Das ursprüngliche Zielbild basiert auf Commit `df48c976`. Die Schritte 0, 1a, 1b und ein föderierter Web-Pilot aus Schritt 2 sind inzwischen implementiert (`21fe4723`, `ea641bb4`, `7b5696ed`). Die folgenden Architekturkapitel enthalten weiterhin spätere Ausbaustufen; den tatsächlichen Lieferumfang, die Einrichtung und verbleibende Grenzen beschreibt Abschnitt 15. Es wurden keine produktiven Instanzen verändert oder echten Einladungen versendet.
|
||||||
|
|
||||||
## 1. Empfehlung
|
## 1. Empfehlung
|
||||||
|
|
||||||
@@ -285,6 +285,14 @@ Als erster nutzbarer Meilenstein wird **ein abgegrenzter Projekt-Unterraum mit e
|
|||||||
|
|
||||||
Unter **Kommunikation → Zusammenarbeit** können interne Teilnehmer ihre eigenen Unterräume und Einladungen öffnen. Projektverantwortliche mit `organisation.projects.write` beziehungsweise Administratoren können Unterräume verwalten und freie Partnerchats anlegen. Der Projektkopf verlinkt ebenfalls auf die Zusammenarbeit. Die Verwaltung eines Raums berechtigt weiterhin nicht zum Lesen seiner Nachrichten; nur der Ersteller wird bei der Anlage automatisch Teilnehmer.
|
Unter **Kommunikation → Zusammenarbeit** können interne Teilnehmer ihre eigenen Unterräume und Einladungen öffnen. Projektverantwortliche mit `organisation.projects.write` beziehungsweise Administratoren können Unterräume verwalten und freie Partnerchats anlegen. Der Projektkopf verlinkt ebenfalls auf die Zusammenarbeit. Die Verwaltung eines Raums berechtigt weiterhin nicht zum Lesen seiner Nachrichten; nur der Ersteller wird bei der Anlage automatisch Teilnehmer.
|
||||||
|
|
||||||
|
Unter **Kommunikation → Chat** erscheinen diese Unterräume ebenfalls in der Raumgruppe **Zusammenarbeit**, einschließlich instanzübergreifender Räume und Ungelesen-Zähler. Einladungen lassen sich dort direkt annehmen oder ablehnen; nach der Annahme öffnet sich der Raum. Nachrichten, Teilnehmer und ausdrücklich freigegebene Projektinformationen werden direkt im normalen Chat angezeigt. **Details und Freigaben** öffnet die bestehende Verwaltungsansicht für den ausgewählten Raum; **Im Chat öffnen** führt zurück. Sichtbar sind ausschließlich Räume mit eigener Teilnahme, keine lediglich verwaltbaren Räume. Interne Chaträume und Unterräume behalten ihre getrennten Berechtigungsprüfungen.
|
||||||
|
|
||||||
|
Die Chatansicht nutzt die volle verfügbare Fläche. Über das Augensymbol lassen sich beliebige Räume persönlich ausblenden und unter **Ausgeblendete Räume** wieder einblenden. Diese Auswahl wird pro Benutzer und Mandant gespeichert und bleibt auf anderen Geräten erhalten; Teilnahme, Nachrichten und Benachrichtigungseinstellungen werden dadurch nicht geändert. Unter **Projekt → Bearbeiten → Projektchat aktiviert** können Projektverantwortliche den internen Projektchat für alle Teilnehmer deaktivieren. Er verschwindet aus der Chatliste und ist auch über direkte Aufrufe nicht mehr les- oder beschreibbar. Beim erneuten Aktivieren bleibt der bisherige Verlauf erhalten. Bestehende und neue Projekte starten mit aktiviertem Projektchat. Separat freigegebene Unterräume bleiben unabhängig davon verwaltbar.
|
||||||
|
|
||||||
|
Der Chat bietet eine Emoji-Auswahl am Eingabefeld und Reaktionen unter jeder Nachricht. Ein Klick auf eine eigene Reaktion entfernt sie wieder; die Anzahl wird für alle Teilnehmer regelmäßig aktualisiert. Pro Nachricht kann ein Bild mit oder ohne Begleittext gesendet werden: über das Bildsymbol, durch Einfügen aus der Zwischenablage oder per Drag-and-drop. JPEG, PNG und WebP bis 10 MB werden im Browser verkleinert; der Server prüft sie erneut und speichert höchstens 1600 × 1600 Pixel beziehungsweise 512 KiB als WebP ohne ursprüngliche Metadaten. Ein Klick auf das Bild vergrößert die Ansicht. Animierte Bilder und andere Dateiformate sind nicht Bestandteil dieser Funktion.
|
||||||
|
|
||||||
|
Bilder und Reaktionen funktionieren in internen Chats, lokalen Unterräumen, Gastchats und über Föderation. Bildabrufe und Reaktionen prüfen jeweils die aktuelle Teilnahme und den Historienbeginn. Nur schreibberechtigte Teilnehmer können Bilder senden oder reagieren; archivierte Räume bleiben lesbar. Die Bilder liegen direkt an der Nachricht in der Datenbank und sind nicht über öffentliche Dateilinks erreichbar. Nachrichtenlisten und Föderationscaches enthalten nur Bildmetadaten; das Bild wird bei Bedarf über die authentifizierte Raum-API abgerufen. Bei nicht erreichbarer Partnerinstanz warten gesendete Bilder wie Textnachrichten auf Zustellung; neue Bildabrufe und Reaktionen benötigen eine Verbindung. Beide Instanzen müssen für die zusätzlichen Funktionen aktualisiert sein.
|
||||||
|
|
||||||
Einladungen unterstützen eigene Mitarbeiter, persönliche FEDEO-Kontaktadressen und Gäste ohne FEDEO-Konto. Eine Kontaktadresse bezeichnet immer genau einen Benutzer in einem Heimatmandanten. Sie lässt sich in der Zusammenarbeit kopieren. Kontakte anderer Mandanten derselben Instanz benötigen keinen Server-Server-Transport. Fremde Instanzen müssen auf beiden Seiten im jeweiligen Mandanten unter **Partnerinstanzen** freigegeben sein. Beim Empfänger erscheint ein Angebot, das ausdrücklich angenommen oder abgelehnt wird.
|
Einladungen unterstützen eigene Mitarbeiter, persönliche FEDEO-Kontaktadressen und Gäste ohne FEDEO-Konto. Eine Kontaktadresse bezeichnet immer genau einen Benutzer in einem Heimatmandanten. Sie lässt sich in der Zusammenarbeit kopieren. Kontakte anderer Mandanten derselben Instanz benötigen keinen Server-Server-Transport. Fremde Instanzen müssen auf beiden Seiten im jeweiligen Mandanten unter **Partnerinstanzen** freigegeben sein. Beim Empfänger erscheint ein Angebot, das ausdrücklich angenommen oder abgelehnt wird.
|
||||||
|
|
||||||
Gäste erhalten ausschließlich einen Projekt-Unterraum. Der Link öffnet `/guest`; ein achtstelliger, zehn Minuten gültiger Einmalcode wird an die eingeladene Adresse geschickt. Nach fünf falschen Versuchen ist der Code gesperrt. Die eigene Gastsitzung gilt zwölf Stunden und wird nicht als reguläres FEDEO-Token akzeptiert. Für spätere Anmeldungen bietet die Gastoberfläche eine als Lesezeichen speicherbare Adresse mit öffentlicher Workspace-ID an. Ohne gewähltes Ablaufdatum gilt die Teilnahme eines Gasts 30 Tage. Der Einladungslink allein erlaubt keinen Inhaltszugriff.
|
Gäste erhalten ausschließlich einen Projekt-Unterraum. Der Link öffnet `/guest`; ein achtstelliger, zehn Minuten gültiger Einmalcode wird an die eingeladene Adresse geschickt. Nach fünf falschen Versuchen ist der Code gesperrt. Die eigene Gastsitzung gilt zwölf Stunden und wird nicht als reguläres FEDEO-Token akzeptiert. Für spätere Anmeldungen bietet die Gastoberfläche eine als Lesezeichen speicherbare Adresse mit öffentlicher Workspace-ID an. Ohne gewähltes Ablaufdatum gilt die Teilnahme eines Gasts 30 Tage. Der Einladungslink allein erlaubt keinen Inhaltszugriff.
|
||||||
@@ -297,17 +305,21 @@ Bei einem vorübergehend nicht erreichbaren Eigentümer sind bereits geladene Na
|
|||||||
|
|
||||||
### Einrichtung
|
### Einrichtung
|
||||||
|
|
||||||
1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql` und `0076_collaboration_federation.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben.
|
1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql`, `0076_collaboration_federation.sql`, `0078_chat_visibility.sql` und `0081_chat_media_reactions.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben.
|
||||||
2. `FEDEO_PUBLIC_URL` auf die öffentliche Webadresse setzen. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. `ENCRYPTION_KEY` muss wie für die übrige Backend-Verschlüsselung ein stabiler 64-stelliger Hex-Schlüssel sein.
|
2. Die vorhandene `API_BASE_URL` wird als Föderationsadresse verwendet, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Gastlinks werden aus deren Ursprung `https://fedeo.example` abgeleitet. Zusätzliche URL-Variablen sind im Standardbetrieb nicht nötig. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. Den vorhandenen `ENCRYPTION_KEY` beibehalten; er muss ein stabiler 64-stelliger Hex-Schlüssel sein.
|
||||||
3. Für den instanzübergreifenden Pilotbetrieb `FEDEO_FEDERATION_URL` auf die kanonische öffentliche HTTPS-API-Adresse setzen, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Anschließend `FEDEO_FEDERATION_ENABLED=true` setzen. Die Variablen sind in `.env.example` und `docker-compose.selfhost.yml` aufgenommen. Föderation ist standardmäßig deaktiviert; lokale Unterräume und Gäste funktionieren unabhängig davon.
|
3. Der Standard ist jetzt `FEDEO_FEDERATION_ENABLED=auto`, auch wenn die Variable nicht gesetzt ist. Mit gültiger HTTPS-API-Adresse auf Port 443 und gültigem Verschlüsselungsschlüssel wird Föderation automatisch bereitgestellt. Lokale HTTP-Entwicklungsumgebungen oder unvollständige Konfigurationen bleiben ohne Föderation startfähig; die Partnerverwaltung nennt den fehlenden Konfigurationswert. `false` schaltet Föderation ausdrücklich ab. `true` erzwingt die Aktivierung und meldet eine ungültige Konfiguration bereits beim Start. Nur bei abweichender Infrastruktur `FEDEO_FEDERATION_URL` oder `FEDEO_PUBLIC_URL` setzen; beide überschreiben die automatisch abgeleiteten Werte. Ein separates Föderationsgateway verändert die Gast-Webadresse nicht automatisch.
|
||||||
4. Der Proxy muss `GET <API-Adresse>/.well-known/fedeo-federation` und `POST <API-Adresse>/federation/v1/rpc` durchreichen und darf JSON-Body oder Signaturheader nicht verändern. Externe Ziele benötigen öffentlich auflösbare HTTPS-Adressen auf Port 443. Private/reservierte IPv4- und IPv6-Ziele sowie Weiterleitungen werden abgewiesen; die geprüfte DNS-Adresse wird für die TLS-Verbindung festgehalten.
|
4. Der Proxy muss `GET <API-Adresse>/.well-known/fedeo-federation` und `POST <API-Adresse>/federation/v1/rpc` durchreichen und darf JSON-Body oder Signaturheader nicht verändern. Externe Ziele benötigen öffentlich auflösbare HTTPS-Adressen auf Port 443. Private/reservierte IPv4- und IPv6-Ziele sowie Weiterleitungen werden abgewiesen; die geprüfte DNS-Adresse wird für die TLS-Verbindung festgehalten.
|
||||||
5. In beiden beteiligten Mandanten die jeweils andere API-Adresse unter **Partnerinstanzen** freigeben. Discovery ermittelt Instanz-ID und öffentlichen Schlüssel über HTTPS. Die erste Freigabe pinnt diese Identität. Ein später abweichender Schlüssel wird abgelehnt und braucht eine gesonderte Betreiberprüfung; eine automatische Schlüsselrotation ist noch nicht vorhanden.
|
5. In beiden beteiligten Mandanten die jeweils andere API-Adresse unter **Partnerinstanzen** freigeben. Discovery ermittelt Instanz-ID und öffentlichen Schlüssel über HTTPS. Die erste Freigabe pinnt diese Identität. Ein später abweichender Schlüssel wird abgelehnt und braucht eine gesonderte Betreiberprüfung; eine automatische Schlüsselrotation ist noch nicht vorhanden.
|
||||||
6. Persönliche Kontaktadresse des Empfängers verwenden, gezielt in einen Unterraum einladen und beim Empfänger annehmen. Für einen ersten Pilotversuch anschließend Rechteänderung, Widerruf und eine vorübergehend unterbrochene Verbindung prüfen.
|
6. Persönliche Kontaktadresse des Empfängers verwenden, gezielt in einen Unterraum einladen und beim Empfänger annehmen. Für einen ersten Pilotversuch anschließend Rechteänderung, Widerruf und eine vorübergehend unterbrochene Verbindung prüfen.
|
||||||
|
|
||||||
Das feste Signaturprofil verwendet Ed25519 mit HTTP Message Signatures und Content-Digest. Signiert werden Methode, vollständige Zieladresse, Inhaltstyp, Inhaltsdigest und Herkunft; Zeitfenster und gespeicherte Nonces verhindern Replay. Das RPC-Protokoll ist Version 1 und ausschließlich für diese FEDEO-Implementierung bestimmt. Es ersetzt weder Matrix-Kompatibilität noch Ende-zu-Ende-Verschlüsselung.
|
Der normale Selfhost-Updater übernimmt die Compose-Datei und Migrationen aus dem Zielrelease; ein manuelles Nachtragen der Föderationsvariablen ist bei verwalteten Installationen nicht erforderlich. Die vorhandene `.env` wird bis auf die Release-Version beibehalten. Eine darin oder in der Override-Datei gesetzte Abschaltung bleibt wirksam. Wurde `FEDEO_FEDERATION_ENABLED=false` aus der früheren Beispielkonfiguration übernommen, muss dieser Wert einmal entfernt oder auf `auto` gesetzt werden, wenn Föderation gewünscht ist. Die automatische Bereitstellung gibt keine Partner oder vorhandenen Chats frei; dafür bleiben die gegenseitige Mandantenfreigabe und explizite Einladung erforderlich.
|
||||||
|
|
||||||
|
Das feste Signaturprofil verwendet Ed25519 mit HTTP Message Signatures und Content-Digest. Signiert werden Methode, vollständige Zieladresse, Inhaltstyp, Inhaltsdigest und Herkunft; Zeitfenster und gespeicherte Nonces verhindern Replay. RPC-Anfragen und Antworten sind auf 1 MiB begrenzt. Die zusätzlichen Aktionen heißen `image.get`, `reactions.get` und `reactions.set`; `messages.send` akzeptiert optional ein Bild. Das RPC-Protokoll ist Version 1 und ausschließlich für diese FEDEO-Implementierung bestimmt. Es ersetzt weder Matrix-Kompatibilität noch Ende-zu-Ende-Verschlüsselung.
|
||||||
|
|
||||||
### Speicherung, Betrieb und Wiederaufnahme
|
### Speicherung, Betrieb und Wiederaufnahme
|
||||||
|
|
||||||
|
Migration `0081_chat_media_reactions.sql` ergänzt Bildspalten an `communication_messages` und `collaboration_messages` sowie die Tabellen `communication_message_reactions` und `collaboration_message_reactions`. Die Bilder werden mit der bestehenden Datenbanksicherung erfasst.
|
||||||
|
|
||||||
Die tatsächlichen Tabellen heißen `collaboration_workspaces`, `collaboration_principals`, `collaboration_rooms`, `collaboration_members`, `collaboration_invitations`, `collaboration_messages` sowie die zugehörigen Gast- und Audittabellen. Der Föderationsteil ergänzt `collaboration_federation_identity`, `collaboration_partners`, `collaboration_federation_nonces`, `collaboration_remote_bindings`, `collaboration_remote_messages` und `collaboration_outbox`. Lokale Identitäten werden bei Nutzung erzeugt; es gibt keinen automatischen Versand oder Backfill bestehender Chatinhalte.
|
Die tatsächlichen Tabellen heißen `collaboration_workspaces`, `collaboration_principals`, `collaboration_rooms`, `collaboration_members`, `collaboration_invitations`, `collaboration_messages` sowie die zugehörigen Gast- und Audittabellen. Der Föderationsteil ergänzt `collaboration_federation_identity`, `collaboration_partners`, `collaboration_federation_nonces`, `collaboration_remote_bindings`, `collaboration_remote_messages` und `collaboration_outbox`. Lokale Identitäten werden bei Nutzung erzeugt; es gibt keinen automatischen Versand oder Backfill bestehender Chatinhalte.
|
||||||
|
|
||||||
Der Worker läuft alle fünf Sekunden, beansprucht Aufträge mit einer zeitlich begrenzten Sperre und wiederholt vorübergehende Fehler mit wachsender Wartezeit. Dauerhafte Ablehnungen oder 20 erfolglose Versuche beenden die Zustellung. Einladung und ausgehender Zustellauftrag werden atomar gespeichert. Nachrichtencache und Synchronisationsposition sind getrennt, damit eine sofort bestätigte eigene Nachricht keine noch nicht abgeholten Nachrichten überspringt. Gastcodes, abgelaufene Sitzungen, Nonces und alte Ratenzähler werden regelmäßig bereinigt. Für Nachrichten, Audit und Zustellhistorie ist noch keine automatische Aufbewahrungsrichtlinie implementiert.
|
Der Worker läuft alle fünf Sekunden, beansprucht Aufträge mit einer zeitlich begrenzten Sperre und wiederholt vorübergehende Fehler mit wachsender Wartezeit. Dauerhafte Ablehnungen oder 20 erfolglose Versuche beenden die Zustellung. Einladung und ausgehender Zustellauftrag werden atomar gespeichert. Nachrichtencache und Synchronisationsposition sind getrennt, damit eine sofort bestätigte eigene Nachricht keine noch nicht abgeholten Nachrichten überspringt. Gastcodes, abgelaufene Sitzungen, Nonces und alte Ratenzähler werden regelmäßig bereinigt. Für Nachrichten, Audit und Zustellhistorie ist noch keine automatische Aufbewahrungsrichtlinie implementiert.
|
||||||
@@ -323,9 +335,11 @@ Reproduzierbar aus `backend/` mit einer ausdrücklich bereitgestellten, entbehrl
|
|||||||
```sh
|
```sh
|
||||||
COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \
|
COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \
|
||||||
node --import tsx --test tests/communicationAccess.test.ts tests/collaboration.test.ts \
|
node --import tsx --test tests/communicationAccess.test.ts tests/collaboration.test.ts \
|
||||||
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts
|
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts tests/chatContent.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Eine visuelle Browserabnahme war in der Arbeitsumgebung mangels verbundenem Browser nicht möglich.
|
Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Die spätere Erweiterung um Emojis, Bilder und Reaktionen wurde zusätzlich mit einer lokalen Browser-Vorschau der tatsächlichen Vue-Komponenten und simulierten API-Antworten geprüft. Die automatisierten Medientests prüfen unter anderem Bildvalidierung, Bildnachrichten ohne Text, Reaktionszähler, Historiengrenzen, Gastrechte, deaktivierte Projektchats und signierte Zustellung zwischen zwei Instanzen.
|
||||||
|
|
||||||
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Push-Benachrichtigungen, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt.
|
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Push-Benachrichtigungen, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt.
|
||||||
|
|
||||||
|
Die automatische Einrichtung wurde zusätzlich mit sechs Konfigurationstests und 58 Updater-Tests geprüft. Die Update-Regressionen verwenden echte Compose-Auswertung mit simuliertem Containerbetrieb und prüfen sowohl Installationen ohne Föderationsvariablen als auch den Erhalt einer ausdrücklichen Abschaltung und eigener URL-Vorgaben.
|
||||||
|
|||||||
33
docs/notification-preferences.md
Normal file
33
docs/notification-preferences.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Persönliche Benachrichtigungen
|
||||||
|
|
||||||
|
Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil.
|
||||||
|
|
||||||
|
## Ereignisse
|
||||||
|
|
||||||
|
| Ereignis | Auslöser | E-Mail ohne Einstellung | Push ohne Einstellung |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `communication.message.direct` | Persönliche Direktnachricht, ohne Absender | aus | an |
|
||||||
|
| `communication.message.mentioned` | Persönliche @-Erwähnung im Raum oder Projektchat | aus | an |
|
||||||
|
| `communication.message.room` | Sonstige Nachricht in einem Raum oder Projektchat mit Mitgliedschaft | aus | aus |
|
||||||
|
| `communication.room.invited` | Aufnahme in einen neu angelegten allgemeinen Chatraum | aus | aus |
|
||||||
|
|
||||||
|
Erwähnte Empfänger erhalten nur das Erwähnungsereignis, nicht zusätzlich das normale Raumereignis. Projektchats sind die nativen FEDEO-Chaträume; föderierte Zusammenarbeit wird hier noch nicht erfasst.
|
||||||
|
|
||||||
|
Persönliche Einstellungen haben Vorrang vor Tenant-Standardwerten. Ohne beide Einträge gelten die obigen Standardwerte. In-App bleibt für Direktnachrichten und Erwähnungen aktiv; die neuen optionalen Raumereignisse sind auch In-App standardmäßig aus. Technische Testnachrichten sind nicht Teil der Matrix.
|
||||||
|
|
||||||
|
## API und Migration
|
||||||
|
|
||||||
|
- `GET /api/notifications/preferences`: wirksame Einstellungen für den Benutzer und Tenant der authentifizierten Session.
|
||||||
|
- `PUT /api/notifications/preferences`: Array aus `{eventType, channel, enabled}`; Kanäle `email` und `push`. Unbekannte Ereignisse, nicht erlaubte Kanäle, nicht-boolsche Werte und Duplikate werden mit 400 abgewiesen. Änderungen werden atomar gespeichert. Benutzer- oder Tenant-IDs im Body ändern den Geltungsbereich nicht.
|
||||||
|
- Migration `0079_notification_matrix` registriert die neuen Ereignisse und übernimmt persönliche Einstellungen und Tenant-Standards von `communication.message.new` für Direktnachrichten und Erwähnungen. Sie deaktiviert den alten Sammelauslöser sowie die bisher nicht angebundenen Anrufereignisse. Historische Benachrichtigungen bleiben erhalten.
|
||||||
|
- E-Mail verwendet den SMTP-Versand der Instanz, mobiles Push den zentralen Service, Browser-Push die Web-Push-Konfiguration der Instanz. Ein aktivierter Kanal setzt eine funktionierende Zustellkonfiguration voraus.
|
||||||
|
|
||||||
|
## Sinnvolle nächste Auslöser
|
||||||
|
|
||||||
|
- Aufgabe zugewiesen, Aufgabe fällig: Empfänger aus der Aufgabenverantwortung; Fälligkeit braucht einen geplanten Lauf und Schutz vor Mehrfachversand.
|
||||||
|
- Urlaubs-/Abwesenheitsantrag eingereicht und entschieden: zuständige Freigabeperson bzw. Antragsteller.
|
||||||
|
- Dokument zur Prüfung oder Freigabe zugewiesen: ausschließlich verantwortliche Benutzer.
|
||||||
|
- Projektzuordnung und wichtige Terminänderung: betroffene Projektmitglieder.
|
||||||
|
- Eingehender und verpasster Anruf: erst mit verlässlichen Anrufzuständen und Empfängerzuordnung anbinden.
|
||||||
|
|
||||||
|
Diese Vorschläge werden noch nicht als schaltbare, aber wirkungslose Ereignisse angezeigt. Sicherheits- und Kontowiederherstellungs-E-Mails bleiben außerhalb der optionalen Matrix.
|
||||||
@@ -1,23 +1,32 @@
|
|||||||
# Epson-Scanner und Dokumentenablage
|
# Epson-Scanner und Dokumentenablage
|
||||||
|
|
||||||
Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scanner-Dateien des aktiven Mandanten, neueste zuerst. Die Seite aktualisiert sich alle zehn Sekunden; Suche, Vorschau und Herunterladen sind verfügbar. Jede übertragene Datei wird unverändert als ein Dokument gespeichert. Ein mehrseitiges PDF bleibt ein Dokument. Der Empfang führt keine Texterkennung, Seitentrennung oder Rechnungsverarbeitung aus.
|
Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scanner-Dateien des aktiven Mandanten, neueste zuerst. Die Seite aktualisiert sich alle zehn Sekunden; Suche, Vorschau und Herunterladen sind verfügbar. Ein Badge am Menüeintrag zählt die offenen, nicht archivierten Dokumente unabhängig von der aktuellen Suche. Es aktualisiert sich alle zehn Sekunden und unmittelbar nach dem Ablegen. Jede übertragene Datei wird unverändert als ein Dokument gespeichert. Ein mehrseitiges PDF bleibt ein Dokument. Nach dem erfolgreichen Empfang wird die KI-Zuordnung im Hintergrund vorbereitet; der Scanner muss darauf nicht warten. Seitentrennung und Rechnungsverarbeitung werden nicht ausgelöst.
|
||||||
|
|
||||||
## In Dateien ablegen
|
## In Dateien ablegen
|
||||||
|
|
||||||
1. Auf den Dateinamen, **Vorschau** oder **Ablegen** klicken. Das Modal zeigt das Original und rechts die Zuordnung.
|
1. Auf den Dateinamen, **Vorschau** oder **Ablegen** klicken. Das Modal zeigt das Original und rechts die Zuordnung.
|
||||||
2. Beim Öffnen wird ein KI-Vorschlag für Ordner und Dokumenttyp aus dem Dokumentinhalt angefordert. Ein gültiger Vorschlag füllt die Auswahlfelder vor und zeigt eine kurze Begründung. Bereits manuell geänderte Werte werden nicht überschrieben. **KI-Vorschlag einsetzen** übernimmt ihn bei Bedarf wieder in die Felder; **Erneut prüfen** fordert eine neue Analyse an.
|
2. Das Modal lädt den bereits vorbereiteten KI-Vorschlag für Ordner und Dokumenttyp. Läuft die Vorbereitung noch, zeigt es den Fortschritt und lädt das Ergebnis automatisch nach. Ein gültiger Vorschlag füllt die Auswahlfelder vor und zeigt eine kurze Begründung. Bereits manuell geänderte Werte werden nicht überschrieben. **KI-Vorschlag einsetzen** übernimmt ihn bei Bedarf wieder in die Felder; **Erneut prüfen** stellt eine abgeschlossene oder fehlgeschlagene Analyse erneut in die Warteschlange.
|
||||||
3. Den vorgeschlagenen vorhandenen FEDEO-Dateiordner oder **Dateien (Hauptebene)** und **Dokumenttyp** prüfen und bei Bedarf ändern. Unterordner erscheinen mit ihrem vollständigen Pfad. Neue Typen lassen sich unter **Stammdaten → Dateitypen** anlegen. Ein vorgeschriebener Ordnertyp ist nicht änderbar.
|
3. Den vorgeschlagenen vorhandenen FEDEO-Dateiordner oder **Dateien (Hauptebene)** und **Dokumenttyp** prüfen und bei Bedarf ändern. Unterordner erscheinen mit ihrem vollständigen Pfad. Neue Typen lassen sich unter **Stammdaten → Dateitypen** anlegen. Ein vorgeschriebener Ordnertyp ist nicht änderbar.
|
||||||
4. Erst **Übernehmen und ablegen** speichert Ordner und Typ am bestehenden Original. Danach verschwindet das Dokument aus dem Eingang und ist unter **Dateien** im gewählten Ordner verfügbar. Es wird keine Kopie erzeugt und keine Rechnungsverarbeitung ausgelöst.
|
4. Optional unter **Zuordnungen** einen Bereich und einen Eintrag auswählen und mit **Zuordnung hinzufügen** vormerken. Wie auf der Dateiseite stehen Projekt, Kunde, Lieferant, Fahrzeug, Objekt, Vertrag, Produkt, Ausgangsbeleg, Eingangsrechnung, Inventarartikel, Überprüfung und Mitarbeiter zur Verfügung. Mehrere Bereiche können gleichzeitig zugeordnet werden, jeweils ein Eintrag pro Bereich. Die Einträge lassen sich nach Namen suchen; bestehende Zuordnungen werden angezeigt und können entfernt werden.
|
||||||
|
5. Erst **Übernehmen und ablegen** speichert Ordner, Typ und vorgemerkte Zuordnungen gemeinsam am bestehenden Original. Danach verschwindet das Dokument aus dem Eingang und ist unter **Dateien** im gewählten Ordner und mit den gewählten Verknüpfungen verfügbar. Es wird keine Kopie erzeugt und keine Rechnungsverarbeitung ausgelöst.
|
||||||
|
|
||||||
Nur aktive Ordner und Typen desselben Mandanten sind zulässig. Bei einem Fehler bleibt das Dokument im Eingang. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag anzulegen.
|
Nur aktive Ordner, Typen und Zuordnungsziele desselben Mandanten sind zulässig. Mitarbeiter müssen aktiv und interne Benutzer sein. Bei einem Fehler bleibt das Dokument im Eingang; auch die übrigen Änderungen werden nicht teilweise gespeichert. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag oder Analyseauftrag anzulegen.
|
||||||
|
|
||||||
### KI-Vorschläge
|
## Archivieren
|
||||||
|
|
||||||
|
**Archivieren** steht sowohl in jeder Listenzeile als auch im Vorschaufenster zur Verfügung. Nach Bestätigung wird die bestehende Datei als archiviert markiert, ohne sie zu löschen. Ordner und Dokumenttyp müssen dafür nicht ausgewählt sein; ungespeicherte Zuordnungen aus der Vorschau werden nicht übernommen. Das Dokument verschwindet aus der Ablage und der Menüzähler wird sofort aktualisiert. Bei einem Fehler bleibt es sichtbar und der Vorgang kann erneut versucht werden.
|
||||||
|
|
||||||
|
Das Original und sein Empfangseintrag bleiben erhalten. Eine identische Wiederholung durch den Scanner aktiviert die Datei nicht erneut. Wartende KI-Aufträge werden für archivierte Dateien übersprungen und Ergebnisse bereits laufender Analysen verworfen. Die Änderung hält Zeitpunkt und Benutzer fest. Bereits unter Dateien abgelegte Dokumente können nicht mehr über die Ablage archiviert werden.
|
||||||
|
|
||||||
|
## KI-Vorschläge
|
||||||
|
|
||||||
Die Analyse nutzt die bestehende zentrale FEDEO-KI-Anbindung oder den konfigurierten `OPENAI_API_KEY`, entsprechend den E-Mail-Zuordnungsvorschlägen. Die Antwort verwendet [Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) und `store: false`. Dokumentinhalt, Dateiname und Kandidatennamen gelten als Daten, nicht als Anweisungen. Das Backend prüft vorgeschlagene IDs und verpflichtende Ordnertypen gegen die aktiven Stammdaten des Mandanten, auch noch einmal nach der Analyse. Der Scanner-Zugang kann diese API nicht nutzen.
|
Die Analyse nutzt die bestehende zentrale FEDEO-KI-Anbindung oder den konfigurierten `OPENAI_API_KEY`, entsprechend den E-Mail-Zuordnungsvorschlägen. Die Antwort verwendet [Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) und `store: false`. Dokumentinhalt, Dateiname und Kandidatennamen gelten als Daten, nicht als Anweisungen. Das Backend prüft vorgeschlagene IDs und verpflichtende Ordnertypen gegen die aktiven Stammdaten des Mandanten, auch noch einmal nach der Analyse. Der Scanner-Zugang kann diese API nicht nutzen.
|
||||||
|
|
||||||
Für die Analyse werden höchstens die ersten fünf PDF-Seiten bzw. die erste Bildseite lokal gelesen (Poppler/Tesseract; JPEG, PNG und TIFF werden für die OCR aufbereitet). Diese Werkzeuge sind im Backend-Image enthalten. Es werden maximal 24.000 Textzeichen und eine begrenzte Auswahl passender Ordner und Typen an die KI geschickt. Die Analyse ist auf Dateien bis 25 MiB begrenzt; die Texterkennung und die einzelnen KI-Anfragen haben Zeitlimits von jeweils 45 Sekunden. Fehlen lesbarer Text, passende Stammdaten oder eine KI-Konfiguration, bleibt die manuelle Zuordnung verfügbar.
|
Für die Analyse werden höchstens die ersten fünf PDF-Seiten bzw. die erste Bildseite lokal gelesen (Poppler/Tesseract; JPEG, PNG und TIFF werden für die OCR aufbereitet). Diese Werkzeuge sind im Backend-Image enthalten. Es werden maximal 24.000 Textzeichen und eine begrenzte Auswahl passender Ordner und Typen an die KI geschickt. Die Analyse ist auf Dateien bis 25 MiB begrenzt; die Texterkennung und die einzelnen KI-Anfragen haben Zeitlimits von jeweils 45 Sekunden. Fehlen lesbarer Text, passende Stammdaten oder eine KI-Konfiguration, bleibt die manuelle Zuordnung verfügbar.
|
||||||
|
|
||||||
Vorschläge und erkannter Text verändern weder das Original noch dessen Klassifizierung. Die Vorschau hält Ergebnisse während des Seitenbesuchs im Speicher, um erneute Analysen beim Wiederöffnen zu vermeiden; ein Mandantenwechsel verwirft sie. Gleichzeitige Analysen desselben Dokuments werden im Backend zusammengefasst, maximal vier Analysen laufen gleichzeitig pro Backend-Prozess. Die fachliche Qualität der KI-Vorschläge sollte bei der Inbetriebnahme mit eigenen Beispieldokumenten geprüft werden.
|
Vorschläge und erkannter Text verändern weder das Original noch dessen Klassifizierung. Der Auftrag entsteht zusammen mit dem Eingangseintrag in der Datenbank. Das Backend verarbeitet offene Aufträge automatisch, auch ohne geöffnete Dokumentenablage. Vorschlag, Status und eine gegebenenfalls nötige Fehlermeldung werden dauerhaft am Eingangseintrag gespeichert; beim Wiederöffnen wird keine neue Analyse ausgelöst. Erkannter Volltext wird hierfür nicht gespeichert. Vorhandene offene Dokumente werden nach der Migration ebenfalls vorbereitet.
|
||||||
|
|
||||||
|
Pro Backend-Prozess läuft eine Analyse gleichzeitig. Datenbanksperren und Auftragskennungen koordinieren mehrere Instanzen. Neue Aufträge werden spätestens beim nächsten Durchlauf nach zwei Sekunden berücksichtigt, wenn kein älterer Auftrag mehr läuft. Nach einem Prozessabbruch werden seit fünf Minuten nicht abgeschlossene Aufträge erneut übernommen; verspätete Ergebnisse eines alten Auftrags können das neuere Ergebnis nicht überschreiben. Abgelegte, archivierte und Dokumente von Mandanten im Wartungsmodus werden nicht neu gestartet. Fehlgeschlagene Analysen lassen sich im Modal erneut anfordern; die manuelle Ablage bleibt möglich. Die fachliche Qualität der KI-Vorschläge sollte bei der Inbetriebnahme mit eigenen Beispieldokumenten geprüft werden.
|
||||||
|
|
||||||
## Einrichtung
|
## Einrichtung
|
||||||
|
|
||||||
@@ -36,7 +45,7 @@ Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit
|
|||||||
|
|
||||||
## Schnittstelle und Betrieb
|
## Schnittstelle und Betrieb
|
||||||
|
|
||||||
- Datenbankmigrationen `0073_scanner_document_inbox` und `0074_scanner_inbox_filing` werden über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt.
|
- Datenbankmigrationen `0073_scanner_document_inbox`, `0074_scanner_inbox_filing` und `0077_scanner_inbox_preparation` werden über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt. Die Hintergrundverarbeitung startet mit dem Backend.
|
||||||
- Der Scanner-Endpunkt liegt außerhalb der Browser-JWT-Anmeldung unter `/scanner-inbox/` und verwendet HTTP Basic mit eigenen zufälligen Zugangsdaten. Ausschließlich über HTTPS veröffentlichen. Passwörter werden nur als SHA-256-Hash gespeichert.
|
- Der Scanner-Endpunkt liegt außerhalb der Browser-JWT-Anmeldung unter `/scanner-inbox/` und verwendet HTTP Basic mit eigenen zufälligen Zugangsdaten. Ausschließlich über HTTPS veröffentlichen. Passwörter werden nur als SHA-256-Hash gespeichert.
|
||||||
- Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/<Dateiname>`. Ein authentifiziertes `GET` auf den Empfangsordner (mit oder ohne abschließenden Schrägstrich) liefert für die Epson-Vorprüfung `200` mit leerem Inhalt. Dokumente lassen sich damit nicht herunterladen. Der Empfangsordner existiert bereits; Unterordner, Lesen von Dateien, Löschen, Umbenennen und Überschreiben über den Scanner-Zugang sind nicht vorgesehen. Dies ist ein begrenzter WebDAV-Upload-Eingang, kein allgemeines Netzlaufwerk. Ein PROPFIND des Ordners gibt keine Dokumentenliste preis.
|
- Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/<Dateiname>`. Ein authentifiziertes `GET` auf den Empfangsordner (mit oder ohne abschließenden Schrägstrich) liefert für die Epson-Vorprüfung `200` mit leerem Inhalt. Dokumente lassen sich damit nicht herunterladen. Der Empfangsordner existiert bereits; Unterordner, Lesen von Dateien, Löschen, Umbenennen und Überschreiben über den Scanner-Zugang sind nicht vorgesehen. Dies ist ein begrenzter WebDAV-Upload-Eingang, kein allgemeines Netzlaufwerk. Ein PROPFIND des Ordners gibt keine Dokumentenliste preis.
|
||||||
- `PUT` enthält genau eine vollständige Datei als Rohdaten, ohne Multipart-Formular. PDF, JPEG, PNG und TIFF werden anhand von Dateiendung und Signatur geprüft, maximal 100 MiB. Keine Prüfung der gesamten Dokumentstruktur auf Beschädigungen.
|
- `PUT` enthält genau eine vollständige Datei als Rohdaten, ohne Multipart-Formular. PDF, JPEG, PNG und TIFF werden anhand von Dateiendung und Signatur geprüft, maximal 100 MiB. Keine Prüfung der gesamten Dokumentstruktur auf Beschädigungen.
|
||||||
@@ -44,8 +53,10 @@ Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit
|
|||||||
- Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; ungültiger Pfad oder leerer Upload ohne explizite Schreibprobe (`Content-Length: 0`): `400`; zu groß: `413`; falsches Format: `415`; Mandantenwartung: `423`. Bei Speicherfehlern gibt es keine Erfolgsbestätigung und keinen unvollständigen Ablageeintrag. Der Scanner kann erneut senden.
|
- Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; ungültiger Pfad oder leerer Upload ohne explizite Schreibprobe (`Content-Length: 0`): `400`; zu groß: `413`; falsches Format: `415`; Mandantenwartung: `423`. Bei Speicherfehlern gibt es keine Erfolgsbestätigung und keinen unvollständigen Ablageeintrag. Der Scanner kann erneut senden.
|
||||||
- Der Reverse Proxy muss die genannten HTTP-Methoden, den Authorization-Header und Uploads bis 100 MiB zulassen. Bei einem abgeschnittenen Pfadpräfix sollte er `X-Forwarded-Prefix` setzen (Traefik StripPrefix erledigt dies). HTTPS endet üblicherweise am bestehenden Proxy.
|
- Der Reverse Proxy muss die genannten HTTP-Methoden, den Authorization-Header und Uploads bis 100 MiB zulassen. Bei einem abgeschnittenen Pfadpräfix sollte er `X-Forwarded-Prefix` setzen (Traefik StripPrefix erledigt dies). HTTPS endet üblicherweise am bestehenden Proxy.
|
||||||
- Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich.
|
- Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich.
|
||||||
- `GET /api/document-inbox/filing-options` liefert verfügbare Ordner und Typen. `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>" }` klassifiziert das Original und markiert den Eingang atomar als abgelegt. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer.
|
- `GET /api/document-inbox/count` liefert `{ "total": 3 }` für den aktiven Mandanten. `GET /api/document-inbox/filing-options` liefert verfügbare Ordner, Typen und `assignmentKinds`. `GET /api/document-inbox/assignment-options?kind=project&search=Beispiel` liefert bis zu 100 passende Ziele mit `id` und `name`; `GET /api/document-inbox/:id/assignments` die bereits bestehenden Verknüpfungen des Dokuments.
|
||||||
- `POST /api/document-inbox/:id/suggestion` liefert `{ "suggestion": { "folderId": "<UUID oder null>", "typeId": "<UUID>", "confidence": "high|medium", "reason": "…" } }` oder einen leeren Vorschlag mit Erklärung. Nur offene, nicht archivierte Dokumente des aktiven Mandanten sind analysierbar. Die Anfrage legt nichts ab; dafür bleibt der separate `/file`-Aufruf erforderlich.
|
- `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>", "assignments": { "project": 123, "customer": 456 } }` klassifiziert das Original, speichert die optionalen Verknüpfungen und markiert den Eingang atomar als abgelegt. Weggelassene Zuordnungen bleiben erhalten; JSON-`null` entfernt eine vorhandene Zuordnung. Die Schlüssel entsprechen den Dateifeldern; `check` und `authProfile` verwenden UUIDs, die übrigen Bereiche numerische IDs. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer.
|
||||||
|
- `POST /api/document-inbox/:id/archive` archiviert eine Eingangsdatei des aktiven Mandanten ohne weitere Pflichtangaben und antwortet mit `{ "id": "<Datei-UUID>", "archived": true }`. Wiederholte Aufrufe sind erfolgreich, ohne den ursprünglichen Archivierungszeitpunkt zu ändern. Bereits abgelegte Dokumente ergeben `409`, fremde oder nicht im Eingang registrierte Dateien `404` und Mandantenwartung `423`. Archivieren und Ablegen sind gegeneinander abgesichert.
|
||||||
|
- `GET /api/document-inbox/:id/suggestion` liest den gespeicherten Status `pending`, `processing`, `ready` oder `failed` und liefert bei Erfolg beispielsweise `{ "status": "ready", "suggestion": { "folderId": "<UUID oder null>", "typeId": "<UUID>", "confidence": "high|medium", "reason": "…" } }`. Ohne passende Zuordnung ist `suggestion` gleich `null` und `message` erklärt den Grund. `POST /api/document-inbox/:id/suggestion` fordert eine erneute Vorbereitung an und antwortet mit `202` und demselben Statusformat, ohne auf die Analyse zu warten. Ein bereits wartender oder laufender Auftrag wird nicht dupliziert. Nur offene, nicht archivierte Dokumente des aktiven Mandanten sind zugänglich. Diese Anfragen legen nichts ab; dafür bleibt der separate `/file`-Aufruf erforderlich.
|
||||||
|
|
||||||
## Lokaler Funktionstest
|
## Lokaler Funktionstest
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben.
|
Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben.
|
||||||
|
|
||||||
|
Für die Chat-Föderation übernimmt der Updater die neuen Backend-Einträge aus der Release-Compose-Datei automatisch. Zusätzliche Einträge in der bestehenden `.env` sind nicht nötig: Die Föderationsadresse folgt `API_BASE_URL`, Gastlinks verwenden deren Ursprung. Der Standard `FEDEO_FEDERATION_ENABLED=auto` aktiviert die Funktion bei gültiger HTTPS-Adresse auf Port 443 und gültigem `ENCRYPTION_KEY`. Ein vorhandenes `false` und eigene URL-Vorgaben bleiben erhalten. Partnerfreigaben und Einladungen werden weiterhin ausdrücklich je Mandant erteilt. Details stehen in der [Föderationsanleitung](chat-foederation-konzept.md#15-implementierter-stand-und-pilotbetrieb).
|
||||||
|
|
||||||
Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die FEDEO-Dienste des jeweiligen Speicherproviders enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Bereits stillgelegte Matrix-Container bleiben erhalten und ihre lokalen Bind-Verzeichnisse werden weiter gesichert. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
|
Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die FEDEO-Dienste des jeweiligen Speicherproviders enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Bereits stillgelegte Matrix-Container bleiben erhalten und ihre lokalen Bind-Verzeichnisse werden weiter gesichert. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
|
||||||
|
|
||||||
Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen.
|
Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen.
|
||||||
@@ -222,6 +224,42 @@ Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem
|
|||||||
7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht.
|
7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht.
|
||||||
8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen.
|
8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen.
|
||||||
9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern.
|
9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern.
|
||||||
|
10. Nach erfolgreichem Abschluss die **zwei neuesten Sicherungen erfolgreicher Updates** behalten und ältere reguläre Update-Sicherungen entfernen. Beide behaltenen Sicherungen werden zuvor vollständig gegen ihre SHA-256-Manifeste geprüft. Die Sicherungen enthalten jeweils den Stand **vor** dem zugehörigen Update. Auftragsprotokolle und Release-Dateien bleiben erhalten; `backup-pruned.json` dokumentiert entfernte Sicherungen. Fehlgeschlagene, unterbrochene und manuell wiederhergestellte Aufträge sowie separate Speichermigrationssicherungen werden nicht automatisch bereinigt.
|
||||||
|
|
||||||
|
### Backup-Aufbewahrung und bereits belegte Hosts
|
||||||
|
|
||||||
|
Die automatische Bereinigung läuft unter derselben Sperre wie das Update und erst
|
||||||
|
nach erfolgreicher öffentlicher Funktionsprüfung. Eine unvollständige oder beschädigte
|
||||||
|
aufzubewahrende Sicherung verhindert das Löschen älterer Sicherungen. Links, fremde
|
||||||
|
Dateien, eingehängte Verzeichnisse und von Containern verwendete Backup-Pfade werden
|
||||||
|
nicht gelöscht. Docker-Images, Container, Volumes und aktive Daten gehören nicht zu
|
||||||
|
dieser Bereinigung.
|
||||||
|
|
||||||
|
Ein Fehler bei der Bereinigung stoppt die aktualisierte Anwendung nicht und macht
|
||||||
|
ein erfolgreiches Update nicht nachträglich zum Wiederherstellungsfall. Das
|
||||||
|
Updateprotokoll enthält eine Meldung; `.updates/backup-retention.json` dokumentiert
|
||||||
|
das Ergebnis. Eine unterbrochene Löschung kann bei der nächsten Bereinigung anhand
|
||||||
|
ihres Löschprotokolls fortgesetzt werden. Die zwei behaltenen Generationen werden
|
||||||
|
dabei erneut geprüft.
|
||||||
|
|
||||||
|
Für bestehende Hosts, deren Platte bereits durch alte Sicherungen belegt ist,
|
||||||
|
die neuen Host-Dienstdateien installieren und zunächst die Vorschau ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py prune-backups --directory /opt/fedeo --dry-run
|
||||||
|
sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py prune-backups --directory /opt/fedeo
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieser Befehl verwendet dieselbe Aufbewahrungsregel und Prüfsummenprüfung, benötigt
|
||||||
|
keinen Image-Download und verändert den aktuellen Update-Status nicht. Laufende
|
||||||
|
Aufträge und offene Wiederherstellungen blockieren ihn. Der letzte erfolgreiche
|
||||||
|
Updateauftrag muss zum installierten Release und zur aktuellen Compose-Datei passen.
|
||||||
|
`check` und fehlgeschlagene Updates löschen keine Sicherungen, auch nicht bei
|
||||||
|
Platzmangel. Für jedes neue Update muss weiterhin Platz für neue Images und eine
|
||||||
|
zusätzliche vollständige Sicherung verfügbar sein; die automatische Aufbewahrung
|
||||||
|
ersetzt keine externe Datensicherung. Der Host-Updater wird durch ein Anwendungsupdate
|
||||||
|
nicht selbst ersetzt: bestehende andere Instanzen benötigen ebenfalls die neuen
|
||||||
|
Host-Dienstdateien.
|
||||||
|
|
||||||
Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `status.json` gespeichert. `events.jsonl` enthält den Verlauf, `commands.log` Fehlermeldungen der Werkzeuge. Das Verzeichnis hat Modus `0700`; Status- und Konfigurationsdateien werden mit `0600` angelegt. Backups und Werkzeugausgaben enthalten vertrauliche Daten und dürfen nicht ungefiltert an eine Oberfläche ausgeliefert werden. Backups werden nicht automatisch gelöscht.
|
Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `status.json` gespeichert. `events.jsonl` enthält den Verlauf, `commands.log` Fehlermeldungen der Werkzeuge. Das Verzeichnis hat Modus `0700`; Status- und Konfigurationsdateien werden mit `0600` angelegt. Backups und Werkzeugausgaben enthalten vertrauliche Daten und dürfen nicht ungefiltert an eine Oberfläche ausgeliefert werden. Backups werden nicht automatisch gelöscht.
|
||||||
|
|
||||||
@@ -264,7 +302,8 @@ cd ../frontend && node --test tests/selfhost-updates.test.cjs
|
|||||||
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.
|
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.
|
||||||
|
|
||||||
`python3 -B scripts/update/tests/host_storage_integration.py` prüft zusätzlich
|
`python3 -B scripts/update/tests/host_storage_integration.py` prüft zusätzlich
|
||||||
MinIO-Migration, Speicherübernahme, zwei Anwendungsupdates und Restore der
|
MinIO-Migration, Speicherübernahme, drei Anwendungsupdates, automatische Aufbewahrung
|
||||||
|
von zwei Sicherungsgenerationen und Restore der
|
||||||
regulären SeaweedFS-Updatesicherung mit echten Docker-Containern. Anwendung und
|
regulären SeaweedFS-Updatesicherung mit echten Docker-Containern. Anwendung und
|
||||||
Release-/HTTP-Antworten sind isolierte Testdaten. Benötigte Images und Aufbau
|
Release-/HTTP-Antworten sind isolierte Testdaten. Benötigte Images und Aufbau
|
||||||
stehen in `scripts/storage/README.md`. Die fachliche Abnahme des veröffentlichten
|
stehen in `scripts/storage/README.md`. Die fachliche Abnahme des veröffentlichten
|
||||||
|
|||||||
@@ -1,5 +1,116 @@
|
|||||||
# FEDEO MCP
|
# FEDEO MCP
|
||||||
|
|
||||||
|
## Status der Ausgangsbelege
|
||||||
|
|
||||||
|
`accounting.outgoing_documents.status.get` lädt den Status eines Belegs anhand seiner numerischen `id`. `accounting.outgoing_documents.status.list` liefert dieselben Statusdaten mit kombinierbaren Filtern und Pagination:
|
||||||
|
|
||||||
|
| Filter | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `state` | Gespeicherter Buchungsstatus, z. B. `Entwurf` oder `Gebucht` |
|
||||||
|
| `paymentStatus` | `Offen`, `Teilbezahlt`, `Bezahlt` oder `Überbezahlt` |
|
||||||
|
| `cancelled` | Ob ein aktiver, fertiggestellter Stornobeleg verknüpft ist |
|
||||||
|
| `type`, `customer`, `project` | Belegart, Kunden-ID und Projekt-ID |
|
||||||
|
| `limit`, `offset` | Seitengröße und Startposition innerhalb der gefilterten Ergebnisse |
|
||||||
|
|
||||||
|
Beispiele für die Parameter von `tools/call`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"accounting.outgoing_documents.status.get","arguments":{"id":42}}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"accounting.outgoing_documents.status.list","arguments":{"state":"Gebucht","paymentStatus":"Teilbezahlt","cancelled":false,"limit":25,"offset":0}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Ergebnis enthält `state`, `cancelled`, `cancellationDocuments`, `paymentStatus`, `amount`, `paidAmount` und `openAmount` sowie Belegnummer, Belegart, Datum, Kunden- und Projekt-ID. Der Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen. Bei Entwürfen, Angeboten, anderen Belegarten und stornierten Rechnungen ist `paymentStatus` gleich `null`.
|
||||||
|
|
||||||
|
Die Beträge folgen der Ausgangsbeleganzeige: Positionsnetto auf drei Nachkommastellen, Nettosumme und Steuergruppen (19 % / 7 %) separat auf zwei Nachkommastellen, anschließend Abzug verknüpfter Abschlagsrechnungen. Zahlungen und Stornobelege müssen zum aktiven Mandanten gehören und dürfen nicht archiviert sein; Stornoentwürfe zählen nicht. `openAmount` ist Belegbetrag abzüglich zugeordneter Zahlungen, auch wenn der Beleg storniert wurde. Es ist kein um den Stornobeleg saldierter Kontostand. Bei negativen Rechnungsbeträgen werden Rückzahlungen in der passenden Zahlungsrichtung ausgewertet.
|
||||||
|
|
||||||
|
Beide Werkzeuge benötigen `accounting.outgoing_documents.read` und verändern keine Belege oder Buchungen. Die berechneten Statusfilter werden vor `limit`/`offset` angewendet. Dafür liest das Backend bei Bedarf mehrere Stapel von jeweils 100 Belegen; große Filter-Offsets können entsprechend mehr Abfragen benötigen.
|
||||||
|
|
||||||
|
## Kalender und Termine
|
||||||
|
|
||||||
|
| Werkzeug | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `organisation.calendar.options` | Konfigurierte Terminarten, Status und Wiederholungsintervalle laden |
|
||||||
|
| `organisation.events.list` | Termine und Serien mit Such-, Zeitraum-, Status- und Ressourcenfiltern auflisten |
|
||||||
|
| `organisation.events.get` | Einen Termin anhand seiner numerischen `id` laden |
|
||||||
|
| `organisation.events.create` | Termin anlegen; `name` und `startDate` sind Pflichtfelder |
|
||||||
|
| `organisation.events.update` | Nur angegebene Felder eines Termins ändern |
|
||||||
|
| `organisation.events.archive` | Termin oder gesamte Wiederholungsserie archivieren |
|
||||||
|
|
||||||
|
Lesende Werkzeuge benötigen `organisation.events.read`. Die Schreibwerkzeuge benötigen `organisation.events.write`; dieses Recht ist in den Bootstrap-Administratorrechten enthalten und muss anderen Rollen ausdrücklich zugewiesen werden. Schreibvorgänge protokollieren Benutzer, Zeitpunkt und Änderung in der Historie. Referenzen auf Projekte, Kunden, Lieferanten, Mitarbeiter, Fahrzeuge und Inventar werden auf Verfügbarkeit im aktiven Mandanten geprüft.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"organisation.events.create","arguments":{"name":"Projektbesprechung","startDate":"2026-10-15T09:00:00+02:00","endDate":"2026-10-15T10:00:00+02:00","state":"Final","repeatInterval":"Wöchentlich","project":42}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeitpunkte erfordern ISO-8601 mit `Z` oder explizitem Offset. Das Ende darf nicht vor dem Start liegen; `endDate: null` ist erlaubt. Statuswerte sind `Entwurf` und `Final` (Standard). Wiederholungen sind `Keine Wiederholung` (Standard), `Täglich`, `Wöchentlich`, `2-wöchentlich`, `Monatlich` und `Jährlich`. Weitere bearbeitbare Felder sind `eventtype`, `quick`, `color`, `notes`, `link`, `customer`, `vendor`, `profiles`, `vehicles`, `inventoryitems` und `inventoryitemgroups`.
|
||||||
|
|
||||||
|
`profiles` und `inventoryitemgroups` sind Listen von UUIDs; `vehicles` und `inventoryitems` enthalten numerische IDs. Beim Update ersetzen übergebene Listen die bisherigen Zuordnungen; `[]` leert eine Liste, `null` leert nullable Einzelfelder. Wiederholungsänderungen und Archivierung betreffen die gesamte Serie.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"organisation.events.list","arguments":{"from":"2026-10-12T00:00:00+02:00","to":"2026-10-19T00:00:00+02:00","state":"Final","limit":25}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`from`/`to` filtern überlappende Termine, wobei `to` exklusiv ist. Mit `profile`, `vehicle`, `inventoryitem` oder `inventoryitemgroup` lässt sich nach einzelnen Ressourcenzuordnungen filtern. Weitere Filter sind `query`, `project`, `customer`, `vendor`, `eventtype`, `state`, `quick` und `repeatInterval`.
|
||||||
|
|
||||||
|
Die Terminliste liefert gespeicherte Serien-Stammsätze, keine berechneten Einzelvorkommen. Bei gesetztem `from` liefert sie standardmäßig auch früher begonnene Wiederholungsserien als Kandidaten mit. `includeRecurring: false` beschränkt die Zeitraumprüfung auf den gespeicherten Start und das Ende. Auch bei Serien gelten `limit`/`offset` für Stammsätze. Abwesenheiten und Kalender-Abonnementlinks gehören nicht zu diesen Werkzeugen.
|
||||||
|
|
||||||
|
## Dokumentenablage und Dateiübertragung
|
||||||
|
|
||||||
|
Die Werkzeuge unter `documents.*` stellen die Dokumentenablage und den Zugriff auf gespeicherte Dateien bereit. Alle arbeiten im aktiven Mandanten und schließen archivierte Dateien aus. Portalbenutzer haben keinen Zugriff, auch wenn ihrer Rolle Dokumentenrechte zugewiesen wurden.
|
||||||
|
|
||||||
|
| Werkzeug | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `documents.inbox.list` / `.count` | Offene Dokumente suchen bzw. zählen |
|
||||||
|
| `documents.folders.list` / `documents.types.list` | Verfügbare Ordner und Dokumenttypen laden |
|
||||||
|
| `documents.assignment_kinds.list` | Unterstützte Zuordnungsarten anzeigen |
|
||||||
|
| `documents.assignments.search` | Zuordnungsziele nach `kind` und optional `search` suchen |
|
||||||
|
| `documents.inbox.assignments.get` | Bestehende Zuordnungen eines offenen Dokuments laden |
|
||||||
|
| `documents.inbox.suggestion.get` / `.refresh` | KI-Ablagevorschlag abrufen bzw. erneut anfordern |
|
||||||
|
| `documents.inbox.file` | Dokument mit Ordner, Typ und optionalen Zuordnungen ablegen |
|
||||||
|
| `documents.inbox.archive` | Offenes Dokument archivieren |
|
||||||
|
| `documents.files.list` / `.get` | Gespeicherte Dateien suchen bzw. Details und vorhandenen extrahierten Text laden |
|
||||||
|
| `documents.files.download` | Originaldatei als Download-URL oder Base64 abrufen |
|
||||||
|
| `documents.files.upload` | PDF oder Scan in die Dokumentenablage hochladen |
|
||||||
|
|
||||||
|
Listen und Suchwerkzeuge verwenden `limit` und `offset` wie unten beschrieben. `search` ist auf 200 Zeichen begrenzt. `documents.files.list` unterstützt zusätzlich `folder`, `type`, `mimeType` sowie Zuordnungsfilter wie `project`, `customer`, `incominginvoice` und `createddocument`. `folder: null` filtert Dateien auf der Hauptebene; ohne `folder` werden alle Ordner durchsucht. Die `id` eines Dokuments ist immer die Datei-UUID, keine Rechnungs- oder Belegnummer.
|
||||||
|
|
||||||
|
Lesende Werkzeuge und Downloads benötigen `documents.read`; Upload, Ablegen, Archivieren und erneute KI-Erkennung benötigen `documents.write`. Beide Rechte sind in den Bootstrap-Administratorrechten enthalten. Anderen Rollen müssen sie ausdrücklich zugewiesen werden. Scanner-Zugangsdaten werden für MCP-Uploads nicht benötigt.
|
||||||
|
|
||||||
|
### PDF-Download
|
||||||
|
|
||||||
|
Zunächst die Datei-UUID beispielsweise über `documents.files.list` mit `createddocument` oder `incominginvoice` ermitteln. Der Aufruf erfolgt über `tools/call` mit diesen Parametern:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"documents.files.download","arguments":{"id":"00000000-0000-4000-8000-000000000001"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Standardmäßig enthält das Ergebnis `url`, `method: "GET"`, `authentication: "Bearer"`, Dateiname, MIME-Type und Größe. Die URL verwendet `API_BASE_URL` und den Pfad `/api/mcp/files/<id>/content`; ohne konfigurierte Basis ist sie relativ. Bei `https://app.fedeo.de/backend` lautet der Pfad somit `https://app.fedeo.de/backend/api/mcp/files/<id>/content`.
|
||||||
|
|
||||||
|
Die URL ist kein öffentlicher Freigabelink: Der GET-Aufruf benötigt denselben `Authorization: Bearer <MCP-Token>`-Header wie der MCP-Aufruf. Berechtigung, Mandant, Portalzugang und Archivstatus werden beim Download erneut geprüft. Der Inhalt wird als Attachment gestreamt; interne Speicherpfade und Tokens erscheinen nicht im Werkzeugergebnis.
|
||||||
|
|
||||||
|
Mit `"format":"base64"` liefert das Werkzeug stattdessen `contentBase64` direkt im Ergebnis, begrenzt auf 20 MiB. Für größere Dateien den URL-Abruf verwenden. Der Download gibt die gespeicherte Originaldatei unverändert zurück; er erzeugt oder konvertiert keine PDFs.
|
||||||
|
|
||||||
|
### Upload und Ablage
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"documents.files.upload","arguments":{"filename":"rechnung.pdf","contentBase64":"<Base64-Dateiinhalt>"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Uploads unterstützen vollständige PDF-, JPEG-, PNG- und TIFF-Dateien bis **20 MiB**. `contentBase64` muss normales, gepaddetes Base64 ohne Data-URL-Präfix enthalten. Dateiname, Dateisignatur und bei PDFs die Abschlussmarkierung werden geprüft. Die Größenbegrenzung des MCP-JSON-Endpunkts berücksichtigt den Base64-Aufschlag; sie gilt für den gesamten Request einschließlich etwaiger Batch-Aufrufe.
|
||||||
|
|
||||||
|
Der Upload liefert `id`, `created`, `checksum`, `filename`, `mimeType` und `size`. Er speichert das Original in der Dokumentenablage und stellt die bestehende KI-Vorbereitung in die Warteschlange. Gleicher Dateiname und gleicher Inhalt liefern bei Wiederholung dieselbe ID mit `created: false`, auch nach Ablage oder Archivierung. Ein anderer Inhalt unter demselben Namen wird abgewiesen. Fehlgeschlagene Uploads rollen die Datenbankänderungen zurück und bereinigen das Speicherobjekt.
|
||||||
|
|
||||||
|
Anschließend Ordner und Dokumenttyp auswählen und die zurückgegebene UUID ablegen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"documents.inbox.file","arguments":{"id":"00000000-0000-4000-8000-000000000001","folder":null,"type":"00000000-0000-4000-8000-000000000002","assignments":{"project":42}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`folder: null` steht für die Hauptebene. `type` ist erforderlich. Zuordnungen verwenden numerische IDs, außer `check` und `authProfile` (UUIDs). Nur angegebene Zuordnungen werden geändert; `null` entfernt eine Zuordnung. Ordner, Dokumenttyp und Zuordnungsziele müssen im aktiven Mandanten verfügbar sein. Ein fester Dokumenttyp des Ordners wird berücksichtigt. Nach erfolgreicher Ablage verschwindet das Dokument aus `inbox.list`, bleibt aber über `files.list`, `.get` und `.download` erreichbar.
|
||||||
|
|
||||||
## Listen und Pagination
|
## Listen und Pagination
|
||||||
|
|
||||||
Alle datensatzbasierten MCP-Listen und Suchwerkzeuge unterstützen `limit` (ganze Zahl 1–100, Standard 25) und `offset` (ganze Zahl ab 0). Das Wiki behält Standard 50. Die statische Liste der Beleg-Steuertypen benötigt keine Pagination.
|
Alle datensatzbasierten MCP-Listen und Suchwerkzeuge unterstützen `limit` (ganze Zahl 1–100, Standard 25) und `offset` (ganze Zahl ab 0). Das Wiki behält Standard 50. Die statische Liste der Beleg-Steuertypen benötigt keine Pagination.
|
||||||
|
|||||||
83
docs/zeiterfassung-2.0.md
Normal file
83
docs/zeiterfassung-2.0.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Zeiterfassung 2.0
|
||||||
|
|
||||||
|
Unter **2.0 → Zeiterfassung** werden Anwesenheiten direkt als Zeitspannen gespeichert. Eine Anwesenheit enthält Beginn, Ende, Pausen und optionale Projektzuordnungen. Arbeitszeit ist die Anwesenheitsdauer abzüglich der Pausen. Die bisherige Erfassung bleibt für noch nicht umgestellte Mitarbeiter verfügbar.
|
||||||
|
|
||||||
|
## Erfassung und Freigaben
|
||||||
|
|
||||||
|
**Kommen** öffnet eine Anwesenheit. **Pause beginnen** und **Pause beenden** erfassen Pausen innerhalb dieser Anwesenheit. **Gehen** schließt die Anwesenheit und gegebenenfalls eine laufende Pause. Manuelle Einträge können mehrere Tage umfassen. Überschneidungen, Pausen außerhalb der Anwesenheit und Projektzeiten innerhalb einer Pause werden abgelehnt. Projektzuordnungen sind exklusive Teilzeiträume; mehrere Projekte können nicht dieselbe Arbeitszeit beanspruchen.
|
||||||
|
|
||||||
|
Abgeschlossene Einträge können eingereicht werden. Berechtigte Personen können sie genehmigen oder mit Begründung ablehnen. Korrekturen verlangen einen Grund und setzen den Status auf **Erfasst** zurück. Damit wird die korrigierte Fassung erneut eingereicht und genehmigt. Der Verlauf enthält die vorherige und die neue Fassung, den Akteur, die Aktion und den Grund. Pausen und Projekte werden gemeinsam mit der Anwesenheit freigegeben.
|
||||||
|
|
||||||
|
Urlaub, Krankheit, Freizeitausgleich, Feiertag und Sonstiges werden als eigene Spannen geführt. Ganztägige Einträge verwenden in der Oberfläche einen ersten und letzten Tag einschließlich. Die API speichert das Enddatum exklusiv. Anwesenheiten und Abwesenheiten dürfen sich nicht überschneiden; bei teilweiser Abwesenheit sind Uhrzeitspannen zu verwenden.
|
||||||
|
|
||||||
|
## Auswertung
|
||||||
|
|
||||||
|
Die Auswertung verwendet Europe/Berlin für Tagesgrenzen und begrenzt jede Spanne auf den gewählten Zeitraum. Nachtschichten zählen anteilig zu den jeweiligen Tagen oder Monaten. Zeitumstellungen werden anhand der tatsächlich vergangenen Zeit berücksichtigt. In manuellen Eingaben wird die angegebene IANA-Zeitzone verwendet; nicht existierende Ortszeiten werden im Formular abgelehnt. Unveränderte Zeitpunkte behalten ihren ursprünglichen Offset. Bei neu eingegebenen, doppelt vorkommenden Ortszeiten verlangt das Formular die Eingabe in UTC. Die API akzeptiert Zeitpunkte mit explizitem UTC-Offset.
|
||||||
|
|
||||||
|
Sollzeit stammt aus den regelmäßigen Wochenarbeitszeiten des Mitarbeiterprofils. Arbeitszeitsummen enthalten nur abgeschlossene Einträge; eingereicht umfasst auch genehmigte Zeiten. Urlaub und Krankheit werden nur genehmigt gutgeschrieben. Ganztägige Abwesenheiten zählen an Arbeitstagen gemäß Sollzeit, zeitweise Abwesenheiten höchstens bis zur Tagessollzeit. Feiertagsausgleich hat Vorrang vor Abwesenheitsgutschriften und zählt pro Datum einmal. Die Summe der Gutschriften überschreitet die Tagessollzeit nicht.
|
||||||
|
|
||||||
|
Freizeitausgleich und Sonstiges werden ausgewiesen, erzeugen aber keine zusätzliche Zeitgutschrift. Ein freier Arbeitstag zum Überstundenabbau reduziert deshalb den Saldo um seine Sollzeit. Aktuelle Anwesenheit und Pausen sind unabhängig vom ausgewählten Auswertungsmonat sichtbar.
|
||||||
|
|
||||||
|
## Berechtigungen
|
||||||
|
|
||||||
|
Eigene Zeiten können angemeldete Mitarbeiter mit Profil im aktiven Mandanten erfassen. Folgende Berechtigungen werden im Backend durchgesetzt:
|
||||||
|
|
||||||
|
| Berechtigung | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `staff.time.read_all` | Zeiten anderer Mitarbeiter lesen |
|
||||||
|
| `staff.time.write_all` | Zeiten anderer Mitarbeiter lesen, erfassen, korrigieren und einreichen |
|
||||||
|
| `staff.time.approve` | Zeiten lesen und eingereichte Zeiten genehmigen oder ablehnen |
|
||||||
|
| `staff.time.activate` | Zeiten lesen, Übernahme prüfen und Mitarbeiter umstellen |
|
||||||
|
|
||||||
|
Administratoren können diese Aktionen ebenfalls ausführen. Die Rechte werden als Berechtigungen der bestehenden Authentifizierungsrollen vergeben. Ein Lese- oder Genehmigungsrecht erlaubt keine Korrektur fremder Zeiten. Die Menüanzeige folgt dem vorhandenen Mandantenmerkmal `staffTime`.
|
||||||
|
|
||||||
|
## Einführung und Datenübernahme
|
||||||
|
|
||||||
|
1. Backend und Frontend gemeinsam bereitstellen. Vor Start des neuen Backends die regulären Datenbankmigrationen ausführen (`npm run migrate` im Backend). Die additive Migration `0080_staff_time_spans` legt die neuen Tabellen und Schutzfunktionen an. Sie aktiviert niemanden automatisch.
|
||||||
|
2. Als Administrator einen Mitarbeiter unter **2.0 → Zeiterfassung** auswählen und **Übernahme prüfen** anklicken.
|
||||||
|
3. Anzahl, Arbeitsminuten und Einträge in der Vorschau prüfen. Offene Einträge, fehlende Enden, unbekannte Ereignisse, uneinheitliche Freigaben oder überlappende Daten aus den beiden alten Modellen verhindern die Aktivierung. Diese Fälle müssen vorab in den Altdaten geklärt werden. Es werden keine Endzeiten erfunden und keine doppelten Einträge stillschweigend verworfen.
|
||||||
|
4. **Geprüfte Daten übernehmen und aktivieren** übernimmt die vollständige Historie dieses Mitarbeiters in einer Transaktion. Wenn sich die Altdaten seit der Vorschau geändert haben, muss die Vorschau neu geladen werden. Wiederholte Aktivierung erzeugt keine Duplikate.
|
||||||
|
5. Nach Aktivierung kommen auch Lesezugriffe auf historische Zeiträume aus dem neuen Modell. Die alten Datensätze bleiben erhalten; Quellverweise verbinden sie mit den übernommenen Spannen. Alte Schreibzugriffe werden durch Datenbanktrigger gesperrt. Eine Rückkehr zum alten Schreibmodell ist kein unterstützter Schalter.
|
||||||
|
|
||||||
|
Die erste Aktivierung sollte mit einem Mitarbeiter erfolgen, dessen Zeiten und Salden bekannt sind. Zusätzlich zur Arbeitsminutensumme sollten die Freigaben, Abwesenheitstage und Projektzuordnungen der Vorschau fachlich geprüft werden. Fehler der bisherigen Ereignisrekonstruktion können zu abweichenden, nun korrekt berechneten Ergebnissen führen. Der Import erhält die ursprünglichen Uhrzeitgrenzen; er interpretiert alte Uhrzeitspannen nicht nachträglich als ganztägige Abwesenheit.
|
||||||
|
|
||||||
|
Vor einer Bereitstellung ist eine Datenbanksicherung nötig. Nach erfolgter Umstellung ist bei Problemen eine Vorwärtskorrektur vorzuziehen. Ein vollständiges Zurücksetzen auf die Sicherung würde zwischenzeitlich erfasste Zeiten verlieren und ist eine gesonderte Betriebsentscheidung.
|
||||||
|
|
||||||
|
## Bestehende Anbindungen
|
||||||
|
|
||||||
|
Die bisherigen Schnittstellen für Start, Ende, Einreichen, Genehmigen, Ablehnen und Auswertung erkennen aktivierte Mitarbeiter. Sie arbeiten dann auf den neuen Spannen. Die Webansicht unter Mitarbeiter → Zeiten wechselt für aktivierte Mitarbeiter zur neuen Oberfläche. Dashboard, Anwesenheitsanzeige und Plantafel lesen über diese kompatiblen Schnittstellen. Die Anwesenheitsanzeige berücksichtigt laufende Pausen.
|
||||||
|
|
||||||
|
Die Mobile-App fasst die kompatiblen Arbeitssegmente zu einer Anwesenheit zusammen und bietet Pausenaktionen für umgestellte Mitarbeiter. RFID-Terminals wechseln zwischen Kommen und Gehen; Gehen beendet eine laufende Pause. Für wiederholte Terminalübertragungen muss derselbe Zeitstempel oder dieselbe `request_id` mitgesendet werden. Scans ohne Zeitstempel und Anfrage-ID gelten jeweils als neue Betätigung. Rückwirkende Offline-Scans werden bei unpassendem Zustand oder Überschneidung abgelehnt und erfordern eine manuelle Korrektur.
|
||||||
|
|
||||||
|
Interne API-Aufrufe bleiben an den authentifizierten Mandanten gebunden. Öffentliche Formulare legen für umgestellte Mitarbeiter eine vollständige Anwesenheit mit Projektzuordnung an. Dokument und Zeit werden gemeinsam in einer Transaktion geschrieben, damit ein Zeitkonflikt keinen halbfertigen Lieferschein hinterlässt. Alte Bearbeitungs- und Projektänderungsaufrufe verweisen auf die vollständige Bearbeitung unter 2.0, damit Pausen oder Freigaben nicht verloren gehen.
|
||||||
|
|
||||||
|
## Technischer Aufbau
|
||||||
|
|
||||||
|
`staff_time_spans` enthält Anwesenheiten und Abwesenheiten mit diskriminierendem Typ, Status und Versionsnummer. Gemeinsam gespeicherte Intervalle erlauben eine einheitliche Prüfung auf Überschneidungen. Zugehörige Tabellen enthalten Pausen, Projektzeiten, Änderungshistorie, Quellverweise sowie aktivierte Mitarbeiter und wiederholungssichere Anfragen. Der Mandantenexport nimmt auch Pausen, Projektzuordnungen und Änderungshistorie mit. Die Dienstschicht liegt unter `backend/src/modules/time-v2`.
|
||||||
|
|
||||||
|
Die API liegt unter `/api/v2/time`. `GET /context` liefert Mitarbeiterauswahl und Rechte, `GET /spans` die Spannen und den aktuellen Zustand, `GET /evaluation` die Auswertung und `GET /spans/:id/history` den Verlauf. `POST /commands` akzeptiert `user_id`, `request_id` und `command`. Bearbeitungen und Statuswechsel enthalten ID und erwartete Versionsnummer. Gleichzeitige Änderungen werden mit HTTP 409 beantwortet. Ein Wiederholungsversuch muss dieselbe Anfrage-ID und identische Daten verwenden. `GET /migration` erzeugt die Vorschau; `POST /activate` verlangt deren Fingerprint.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
Fachliche Tests laufen mit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --import ./backend/node_modules/tsx/dist/loader.mjs --test backend/tests/timeV2.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Die PostgreSQL- und HTTP-Integrationstests benötigen eine ausdrücklich bestimmte, entbehrliche Testdatenbank namens `time_v2_test`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TIME_V2_TEST_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/time_v2_test \
|
||||||
|
node --import ./backend/node_modules/tsx/dist/loader.mjs --test backend/tests/timeV2.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeder Integrationstest erzeugt ein eigenes Schema und entfernt es anschließend. Ohne die Variable werden diese Integrationstests übersprungen. Getestet werden unter anderem Pausenabzug, Monatsgrenzen, Zeitumstellungen, Feiertage, Migration, Wiederholungen, Parallelzugriffe, Versionskonflikte, Statuswechsel und Mandantentrennung. Zusätzlich sind Backend-Typecheck und Frontend-Build auszuführen.
|
||||||
|
|
||||||
|
Die Browserprüfung verwendet ausschließlich simulierte API-Antworten und prüft den gebauten Frontend-Stand. Sie benötigt eine lokale Playwright-Installation und einen laufenden Frontend-Previewserver:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PLAYWRIGHT_MODULE=/ABSOLUTER/PFAD/playwright/index.mjs \
|
||||||
|
TIME_V2_FRONTEND_URL=http://127.0.0.1:3148 \
|
||||||
|
node frontend/tests/time-v2.browser.mjs
|
||||||
|
```
|
||||||
@@ -13,7 +13,7 @@ ARG FEDEO_VERSION=0.0.0-dev
|
|||||||
ARG FEDEO_REVISION=unknown
|
ARG FEDEO_REVISION=unknown
|
||||||
ENV FEDEO_VERSION=$FEDEO_VERSION
|
ENV FEDEO_VERSION=$FEDEO_VERSION
|
||||||
ENV FEDEO_REVISION=$FEDEO_REVISION
|
ENV FEDEO_REVISION=$FEDEO_REVISION
|
||||||
RUN npm run build
|
RUN node --test tests/time-v2.test.cjs && npm run build
|
||||||
|
|
||||||
# --- Stage 2: Runtime ---
|
# --- Stage 2: Runtime ---
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|||||||
65
frontend/components/ChatComposer.vue
Normal file
65
frontend/components/ChatComposer.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
import { prepareChatImage } from '~/utils/chat-image'
|
||||||
|
|
||||||
|
const props = defineProps({ modelValue: { type: String, default: '' }, image: { type: Object, default: null }, busy: Boolean, disabled: Boolean,
|
||||||
|
placeholder: { type: String, default: 'Nachricht schreiben …' } })
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:image', 'send'])
|
||||||
|
const input = ref(null), textarea = ref(null), preparing = ref(false), error = ref(''), emojisOpen = ref(false)
|
||||||
|
const emojis = ['😀', '😊', '😂', '🥰', '😎', '🤔', '😮', '😢', '😅', '🙈', '👍', '👎', '👋', '🙏', '💪', '👏', '❤️', '🎉', '🔥', '✅', '❌', '🚀', '💡', '📅', '📷', '☕', '🎂', '🎁', '🛠️', '🏗️', '🤝', '👌']
|
||||||
|
let generation = 0
|
||||||
|
const locked = computed(() => props.disabled || props.busy || preparing.value)
|
||||||
|
function send(event) {
|
||||||
|
if (event?.isComposing || locked.value || (!props.modelValue.trim() && !props.image)) return
|
||||||
|
emojisOpen.value = false; emit('send')
|
||||||
|
}
|
||||||
|
async function insertEmoji(emoji) {
|
||||||
|
if (locked.value) return
|
||||||
|
const start = textarea.value?.selectionStart ?? props.modelValue.length, end = textarea.value?.selectionEnd ?? start
|
||||||
|
const value = props.modelValue.slice(0, start) + emoji + props.modelValue.slice(end)
|
||||||
|
if (value.length > 10000) return
|
||||||
|
emit('update:modelValue', value); emojisOpen.value = false
|
||||||
|
await nextTick(); textarea.value?.focus(); textarea.value?.setSelectionRange(start + emoji.length, start + emoji.length)
|
||||||
|
}
|
||||||
|
async function attach(file) {
|
||||||
|
if (!file || locked.value) return
|
||||||
|
const version = ++generation
|
||||||
|
preparing.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const image = await prepareChatImage(file)
|
||||||
|
if (version === generation && !props.disabled) emit('update:image', image)
|
||||||
|
} catch (e) { if (version === generation) error.value = e.message || 'Das Bild konnte nicht gelesen werden.' }
|
||||||
|
finally { if (version === generation) preparing.value = false }
|
||||||
|
}
|
||||||
|
function paste(event) {
|
||||||
|
const image = [...(event.clipboardData?.files || [])].find(file => file.type.startsWith('image/'))
|
||||||
|
if (image) { event.preventDefault(); void attach(image) }
|
||||||
|
}
|
||||||
|
function drop(event) {
|
||||||
|
if (event.dataTransfer?.files?.length) { event.preventDefault(); void attach(event.dataTransfer.files[0]) }
|
||||||
|
}
|
||||||
|
watch(() => props.disabled, disabled => { if (disabled) { ++generation; preparing.value = false; emojisOpen.value = false } })
|
||||||
|
onBeforeUnmount(() => { ++generation })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="shrink-0 space-y-2 border-t border-gray-200 bg-white p-3 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 sm:p-4" @submit.prevent="send" @dragover.prevent @drop="drop">
|
||||||
|
<p v-if="error" role="alert" class="text-sm text-red-700">{{ error }}</p>
|
||||||
|
<div v-if="image" class="flex items-center gap-3 rounded-lg bg-gray-50 p-2 dark:bg-gray-800">
|
||||||
|
<img :src="image.dataUrl" :alt="image.name" class="size-16 rounded object-contain" />
|
||||||
|
<span class="min-w-0 flex-1 truncate text-xs">{{ image.name }}</span>
|
||||||
|
<UButton icon="i-heroicons-x-mark" aria-label="Bild entfernen" color="neutral" variant="ghost" :disabled="locked" @click="emit('update:image', null)" />
|
||||||
|
</div>
|
||||||
|
<div v-if="emojisOpen" class="flex max-h-32 flex-wrap gap-1 overflow-y-auto rounded-lg border border-gray-200 p-2" role="group" aria-label="Emoji auswählen">
|
||||||
|
<button v-for="emoji in emojis" :key="emoji" type="button" :aria-label="`${emoji} einfügen`" class="rounded p-2 text-xl hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-green-600" @click="insertEmoji(emoji)">{{ emoji }}</button>
|
||||||
|
</div>
|
||||||
|
<label class="block"><span class="sr-only">Nachricht</span><textarea ref="textarea" :value="modelValue" rows="2" maxlength="10000" :disabled="locked" :placeholder="placeholder"
|
||||||
|
class="max-h-36 w-full resize-y rounded-lg border border-gray-300 bg-transparent p-2 text-sm dark:border-gray-600" @input="emit('update:modelValue', $event.target.value)" @keydown.enter.exact.prevent="send" @paste="paste" /></label>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<UButton icon="i-heroicons-face-smile" aria-label="Emojis auswählen" title="Emojis" color="neutral" variant="ghost" :aria-expanded="emojisOpen" :disabled="locked" @click="emojisOpen = !emojisOpen" />
|
||||||
|
<UButton icon="i-heroicons-photo" aria-label="Bild anhängen" title="Bild anhängen (JPEG, PNG, WebP, bis 10 MB)" color="neutral" variant="ghost" :loading="preparing" :disabled="locked" @click="input?.click()" />
|
||||||
|
<input ref="input" type="file" accept="image/jpeg,image/png,image/webp" class="hidden" aria-label="Bilddatei" :disabled="locked" @change="attach($event.target.files?.[0]); $event.target.value = ''" />
|
||||||
|
<span class="hidden min-w-0 flex-1 text-xs text-gray-400 sm:block">Enter sendet · Umschalt+Enter für neue Zeile</span>
|
||||||
|
<UButton type="submit" class="ml-auto" icon="i-heroicons-paper-airplane" :loading="busy" :disabled="locked || (!modelValue.trim() && !image)">Senden</UButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
38
frontend/components/ChatImage.vue
Normal file
38
frontend/components/ChatImage.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({ api: { type: Function, required: true }, endpoint: { type: String, required: true }, message: { type: Object, required: true } })
|
||||||
|
const dataUrl = ref(''), error = ref(''), loading = ref(false), expanded = ref(false)
|
||||||
|
// Reserve dimensions while fetching so a new photo does not move below the composer.
|
||||||
|
const imageStyle = computed(() => {
|
||||||
|
const { width, height } = props.message.image
|
||||||
|
const scale = expanded.value ? 1 : Math.min(1, 320 / width, 256 / height)
|
||||||
|
return { width: `${Math.round(width * scale)}px`, aspectRatio: `${width} / ${height}`, maxHeight: expanded.value ? '70vh' : undefined }
|
||||||
|
})
|
||||||
|
let generation = 0
|
||||||
|
async function load() {
|
||||||
|
const version = ++generation
|
||||||
|
dataUrl.value = ''; error.value = ''; expanded.value = false; loading.value = false
|
||||||
|
if (props.message.pending || props.message.failed) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await props.api(`${props.endpoint}/image?messageId=${encodeURIComponent(props.message.id)}`)
|
||||||
|
if (version !== generation) return
|
||||||
|
// Even a partner instance can only supply bounded raster data, never an external tracking URL.
|
||||||
|
if (typeof result.dataUrl !== 'string' || result.dataUrl.length > 700000 || !/^data:image\/(webp|png|jpeg);base64,[A-Za-z0-9+/]+=*$/.test(result.dataUrl)) throw new Error('Ungültiges Bild')
|
||||||
|
dataUrl.value = result.dataUrl
|
||||||
|
} catch { if (version === generation) error.value = 'Bild derzeit nicht verfügbar.' }
|
||||||
|
finally { if (version === generation) loading.value = false }
|
||||||
|
}
|
||||||
|
watch(() => [props.endpoint, props.message.id, props.message.pending, props.message.failed], load, { immediate: true })
|
||||||
|
onBeforeUnmount(() => { ++generation; dataUrl.value = '' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="my-2 max-w-full" :style="imageStyle">
|
||||||
|
<button v-if="dataUrl" type="button" class="block max-w-full rounded-lg focus-visible:ring-2" :aria-label="expanded ? 'Bild verkleinern' : 'Bild vergrößern'" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||||
|
<img :src="dataUrl" :alt="message.image.name" :width="message.image.width" :height="message.image.height" class="h-auto max-w-full rounded-lg object-contain" :class="expanded ? 'max-h-[70vh]' : 'max-h-64'" />
|
||||||
|
</button>
|
||||||
|
<p v-else-if="loading" class="text-xs opacity-70" role="status">Bild wird geladen …</p>
|
||||||
|
<p v-else-if="message.pending || message.failed" class="text-xs opacity-70">📷 {{ message.image.name }} · {{ message.failed ? 'Nicht zugestellt' : 'Wartet auf Zustellung' }}</p>
|
||||||
|
<button v-else-if="error" type="button" class="text-xs underline" @click="load">{{ error }} Erneut laden</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
19
frontend/components/ChatReactionBar.vue
Normal file
19
frontend/components/ChatReactionBar.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({ reactions: { type: Array, default: () => [] }, disabled: Boolean, busy: Boolean })
|
||||||
|
const emit = defineEmits(['react'])
|
||||||
|
const open = ref(false)
|
||||||
|
const emojis = ['👍', '❤️', '😂', '🎉', '😮', '😢', '🙏', '🔥', '✅', '👎']
|
||||||
|
function react(emoji) { if (!props.disabled && !props.busy) { open.value = false; emit('react', emoji) } }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-2 flex max-w-full flex-wrap items-center gap-1 text-gray-700 dark:text-gray-200">
|
||||||
|
<button v-for="reaction in reactions" :key="reaction.emoji" type="button" class="rounded-full border px-2 py-0.5 text-xs"
|
||||||
|
:class="reaction.own ? 'border-green-400 bg-green-100 text-green-900' : 'border-gray-200 bg-white text-gray-700'"
|
||||||
|
:aria-pressed="reaction.own" :aria-label="`${reaction.emoji}: ${reaction.count}${reaction.own ? ', eigene Reaktion entfernen' : ', reagieren'}`" :disabled="disabled || busy" @click="react(reaction.emoji)">{{ reaction.emoji }} {{ reaction.count }}</button>
|
||||||
|
<button v-if="!disabled" type="button" class="rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-100" aria-label="Reaktion hinzufügen" :aria-expanded="open" :disabled="busy" @click="open = !open">☺ +</button>
|
||||||
|
<div v-if="open && !disabled" class="flex basis-full flex-wrap gap-1 rounded-lg bg-white p-1" role="group" aria-label="Reaktion auswählen">
|
||||||
|
<button v-for="emoji in emojis" :key="emoji" type="button" :aria-label="`Mit ${emoji} reagieren`" class="rounded p-1 hover:bg-gray-100" :disabled="busy" @click="react(emoji)">{{ emoji }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
39
frontend/components/ChatReactions.vue
Normal file
39
frontend/components/ChatReactions.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({ api: { type: Function, required: true }, endpoint: { type: String, required: true }, messages: { type: Array, required: true }, disabled: Boolean })
|
||||||
|
const emit = defineEmits(['error'])
|
||||||
|
const reactions = ref({}), busy = ref(false)
|
||||||
|
let timer, disposed = false, fetching = false, revision = 0
|
||||||
|
const ids = computed(() => props.messages.filter(m => !m.pending && !m.failed).map(m => m.id))
|
||||||
|
async function refresh() {
|
||||||
|
if (disposed || fetching || busy.value || document.hidden) return
|
||||||
|
const version = ++revision, endpoint = props.endpoint, list = [...ids.value]
|
||||||
|
fetching = true
|
||||||
|
try {
|
||||||
|
const next = {}
|
||||||
|
for (let index = 0; index < list.length; index += 100) {
|
||||||
|
const result = await props.api(`${endpoint}/reactions?ids=${encodeURIComponent(list.slice(index, index + 100).join(','))}`)
|
||||||
|
Object.assign(next, result.reactions || {})
|
||||||
|
if (disposed || version !== revision || props.endpoint !== endpoint) return
|
||||||
|
}
|
||||||
|
if (!disposed && version === revision) reactions.value = next
|
||||||
|
} catch { /* Temporary polling errors must not obscure messages. */ }
|
||||||
|
finally { fetching = false }
|
||||||
|
}
|
||||||
|
async function react(messageId, emoji) {
|
||||||
|
if (disposed || busy.value || props.disabled || !ids.value.includes(messageId)) return
|
||||||
|
const version = ++revision, endpoint = props.endpoint
|
||||||
|
const active = !(reactions.value[messageId] || []).some(r => r.emoji === emoji && r.own)
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const result = await props.api(`${endpoint}/reactions`, { method: 'PUT', body: { messageId, emoji, active } })
|
||||||
|
if (!disposed && version === revision && endpoint === props.endpoint) reactions.value = { ...reactions.value, [messageId]: result.reactions?.[messageId] || [] }
|
||||||
|
} catch (error) { if (!disposed && version === revision) emit('error', error) }
|
||||||
|
finally { if (!disposed) busy.value = false }
|
||||||
|
}
|
||||||
|
watch(() => props.endpoint, () => { ++revision; reactions.value = {} })
|
||||||
|
watch(ids, () => { void refresh() }, { immediate: true })
|
||||||
|
onMounted(() => { timer = setInterval(refresh, 3000) })
|
||||||
|
onBeforeUnmount(() => { disposed = true; ++revision; clearInterval(timer) })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template><slot :reactions="reactions" :react="react" :busy="busy" /></template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' } })
|
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
|
||||||
const emit = defineEmits(['changed', 'unauthorized'])
|
const emit = defineEmits(['changed', 'unauthorized'])
|
||||||
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('')
|
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null), error = ref('')
|
||||||
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
||||||
let generation = 0, timer, polling = false, pending = null
|
let generation = 0, timer, polling = false, pending = null
|
||||||
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
||||||
@@ -20,7 +20,7 @@ const scroll = async () => { await nextTick(); if (viewport.value) viewport.valu
|
|||||||
function failed(e) {
|
function failed(e) {
|
||||||
error.value = messageError(e)
|
error.value = messageError(e)
|
||||||
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
|
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
|
||||||
room.value = null; messages.value = []; members.value = []
|
room.value = null; messages.value = []; members.value = []; draftImage.value = null
|
||||||
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
|
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,8 +32,8 @@ async function read(id, version) {
|
|||||||
}
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
const version = ++generation, id = props.roomId
|
const version = ++generation, id = props.roomId
|
||||||
room.value = null; messages.value = []; members.value = []; draft.value = ''; error.value = ''; pending = null
|
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null
|
||||||
loading.value = true
|
loading.value = true; sending.value = false; olderLoading.value = false
|
||||||
try {
|
try {
|
||||||
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
|
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
|
||||||
if (version !== generation) return
|
if (version !== generation) return
|
||||||
@@ -75,18 +75,18 @@ async function older() {
|
|||||||
finally { olderLoading.value = false }
|
finally { olderLoading.value = false }
|
||||||
}
|
}
|
||||||
async function send() {
|
async function send() {
|
||||||
const text = draft.value.trim()
|
const text = draft.value.trim(), image = draftImage.value
|
||||||
if (!text || sending.value || !room.value?.canSend) return
|
if ((!text && !image) || sending.value || !room.value?.canSend) return
|
||||||
const version = generation, id = props.roomId
|
const version = generation, id = props.roomId
|
||||||
if (!pending || pending.text !== text) pending = { text, clientId: crypto.randomUUID() }
|
if (!pending || pending.text !== text || pending.image !== image) pending = { text, image, clientId: crypto.randomUUID() }
|
||||||
sending.value = true; error.value = ''
|
sending.value = true; error.value = ''
|
||||||
try {
|
try {
|
||||||
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
||||||
if (version !== generation) return
|
if (version !== generation) return
|
||||||
merge([message]); draft.value = ''; pending = null
|
merge([message]); draft.value = ''; draftImage.value = null; pending = null
|
||||||
await scroll(); await read(id, version)
|
await scroll(); await read(id, version)
|
||||||
} catch (e) { if (version === generation) failed(e) }
|
} catch (e) { if (version === generation) failed(e) }
|
||||||
finally { sending.value = false }
|
finally { if (version === generation) sending.value = false }
|
||||||
}
|
}
|
||||||
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
watch(() => props.roomId, load, { immediate: true })
|
watch(() => props.roomId, load, { immediate: true })
|
||||||
@@ -95,9 +95,9 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex min-h-[32rem] min-w-0 flex-col rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
|
<section class="flex min-w-0 flex-col bg-white dark:bg-gray-900" :class="embedded ? 'min-h-0 overflow-hidden' : 'min-h-[32rem] rounded-xl border border-gray-200 dark:border-gray-700'">
|
||||||
<header class="border-b border-gray-200 p-4 dark:border-gray-700">
|
<header class="border-b border-gray-200 p-4 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2>
|
<div class="flex flex-wrap items-center justify-between gap-2"><h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2><slot name="actions" /></div>
|
||||||
<p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p>
|
<p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p>
|
||||||
<p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
|
<p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -110,21 +110,22 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
|||||||
<p v-if="room.overview.date">Termin: {{ room.overview.date }}</p>
|
<p v-if="room.overview.date">Termin: {{ room.overview.date }}</p>
|
||||||
<p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
|
<p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div ref="viewport" class="max-h-[60vh] min-h-[18rem] flex-1 space-y-3 overflow-y-auto p-4" aria-live="polite">
|
<div ref="viewport" class="flex-1 space-y-3 overflow-y-auto p-4" :class="embedded ? 'min-h-0' : 'max-h-[60vh] min-h-[18rem]'" aria-live="polite">
|
||||||
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
|
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
|
||||||
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
|
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
|
||||||
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>
|
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>
|
||||||
|
<ChatReactions :key="roomId" v-slot="{ reactions, react, busy }" :api="api" :endpoint="endpoint(roomId)" :messages="messages" :disabled="!room?.canSend || room?.offline" @error="error = messageError($event)">
|
||||||
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
|
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
|
||||||
<div class="max-w-[85%] rounded-xl px-4 py-2" :class="message.own ? 'bg-green-700 text-white' : 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'">
|
<div class="max-w-[85%] rounded-xl px-4 py-2" :class="message.own ? 'bg-green-700 text-white' : 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'">
|
||||||
<p class="text-xs font-semibold">{{ message.own ? 'Du' : message.senderName }}</p>
|
<p class="text-xs font-semibold">{{ message.own ? 'Du' : message.senderName }}</p>
|
||||||
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
|
<ChatImage v-if="message.image" :api="api" :endpoint="endpoint(roomId)" :message="message" />
|
||||||
|
<p v-if="message.body" class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
|
||||||
<p class="mt-1 text-right text-[10px] opacity-70">{{ time(message.createdAt) }}{{ message.pending ? ' · Wartet auf Zustellung' : message.failed ? ' · Zustellung fehlgeschlagen' : ' · Bestätigt' }}</p>
|
<p class="mt-1 text-right text-[10px] opacity-70">{{ time(message.createdAt) }}{{ message.pending ? ' · Wartet auf Zustellung' : message.failed ? ' · Zustellung fehlgeschlagen' : ' · Bestätigt' }}</p>
|
||||||
|
<ChatReactionBar :reactions="reactions[message.id] || []" :busy="busy" :disabled="!room?.canSend || room?.offline || !!message.pending || !!message.failed" @react="react(message.id, $event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ChatReactions>
|
||||||
</div>
|
</div>
|
||||||
<form v-if="room" class="flex items-end gap-3 border-t border-gray-200 p-4 dark:border-gray-700" @submit.prevent="send">
|
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
|
||||||
<label class="min-w-0 flex-1"><span class="sr-only">Nachricht</span><textarea v-model="draft" rows="2" maxlength="10000" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" class="w-full resize-none rounded-lg border border-gray-300 p-2 text-sm dark:border-gray-600" @keydown.enter.exact.prevent="send" /></label>
|
|
||||||
<UButton type="submit" :loading="sending" :disabled="!room.canSend || !draft.trim()">Senden</UButton>
|
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const { count: inboxCount } = useDocumentInboxCount()
|
||||||
const { has } = usePermission()
|
const { has } = usePermission()
|
||||||
const tenantExtraModules = computed(() => {
|
const tenantExtraModules = computed(() => {
|
||||||
const modules = auth.activeTenantData?.extraModules
|
const modules = auth.activeTenantData?.extraModules
|
||||||
@@ -55,7 +56,8 @@ const links = computed(() => {
|
|||||||
featureEnabled("files") ? {
|
featureEnabled("files") ? {
|
||||||
label: "Dokumentenablage",
|
label: "Dokumentenablage",
|
||||||
to: "/organisation/dokumentenablage",
|
to: "/organisation/dokumentenablage",
|
||||||
icon: "i-heroicons-inbox-arrow-down"
|
icon: "i-heroicons-inbox-arrow-down",
|
||||||
|
badge: inboxCount.value > 0 ? String(inboxCount.value) : undefined,
|
||||||
} : null,
|
} : null,
|
||||||
featureEnabled("files") ? {
|
featureEnabled("files") ? {
|
||||||
label: "Dateien",
|
label: "Dateien",
|
||||||
@@ -476,6 +478,12 @@ const links = computed(() => {
|
|||||||
icon: "i-heroicons-user-group",
|
icon: "i-heroicons-user-group",
|
||||||
children: visibleContactsChildren
|
children: visibleContactsChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
|
...(featureEnabled("staffTime") ? [{
|
||||||
|
label: "2.0",
|
||||||
|
icon: "i-heroicons-sparkles",
|
||||||
|
defaultOpen: false,
|
||||||
|
children: [{ label: "Zeiterfassung", to: "/v2/time", icon: "i-heroicons-clock" }]
|
||||||
|
}] : []),
|
||||||
...(visibleStaffChildren.length > 0 ? [{
|
...(visibleStaffChildren.length > 0 ? [{
|
||||||
label: "Mitarbeiter",
|
label: "Mitarbeiter",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
|
|||||||
67
frontend/components/NotificationPreferences.vue
Normal file
67
frontend/components/NotificationPreferences.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup>
|
||||||
|
const { $api } = useNuxtApp()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const events = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
let generation = 0
|
||||||
|
watch(() => auth.activeTenant, async () => {
|
||||||
|
const current = ++generation
|
||||||
|
events.value = []
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
try {
|
||||||
|
const result = await $api('/api/notifications/preferences')
|
||||||
|
if (current === generation) events.value = result.events
|
||||||
|
} catch (err) {
|
||||||
|
if (current === generation) error.value = err?.data?.error || err.message || 'Einstellungen konnten nicht geladen werden.'
|
||||||
|
} finally {
|
||||||
|
if (current === generation) loading.value = false
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
async function save() {
|
||||||
|
const current = generation
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
try {
|
||||||
|
await $api('/api/notifications/preferences', { method: 'PUT', body: events.value.flatMap(event =>
|
||||||
|
Object.entries(event.channels).map(([channel, enabled]) => ({ eventType: event.eventKey, channel, enabled }))) })
|
||||||
|
if (current === generation) success.value = 'Einstellungen gespeichert.'
|
||||||
|
} catch (err) {
|
||||||
|
if (current === generation) error.value = err?.data?.error || err.message || 'Speichern fehlgeschlagen.'
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold">Meine Benachrichtigungen</h2>
|
||||||
|
<p>Diese Einstellungen gelten nur für dein Profil im aktuellen Mandanten {{ auth.activeTenantData?.name || '' }}. Für andere Mandanten wechsle zuerst das Profil.</p>
|
||||||
|
<p class="text-sm text-gray-500">E-Mail geht an deine Konto-Adresse. Push erreicht deine registrierten Geräte und Browser. Testnachrichten bleiben unabhängig davon möglich.</p>
|
||||||
|
<p v-if="loading" role="status">Einstellungen werden geladen …</p>
|
||||||
|
<p v-if="error" role="alert" class="text-red-600">{{ error }}</p>
|
||||||
|
<p v-if="success" role="status" class="text-green-600">{{ success }}</p>
|
||||||
|
<template v-if="!loading && events.length">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead><tr><th class="p-2">Auslöser</th><th class="p-2">E-Mail</th><th class="p-2">Push</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="event in events" :key="event.eventKey" class="border-t border-gray-200">
|
||||||
|
<td class="p-2"><strong>{{ event.displayName }}</strong><p class="text-sm text-gray-500">{{ event.description }}</p></td>
|
||||||
|
<td v-for="channel in ['email', 'push']" :key="channel" class="p-2">
|
||||||
|
<input v-if="channel in event.channels" v-model="event.channels[channel]" type="checkbox" :disabled="saving"
|
||||||
|
:aria-label="`${event.displayName}: ${channel === 'email' ? 'E-Mail' : 'Push'}`" class="h-5 w-5" />
|
||||||
|
<span v-else>–</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<UButton :loading="saving" :disabled="saving" @click="save">Speichern</UButton>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({ row: { type: Object, required: true } })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span v-if="row.chatEnabled === true" class="text-primary">Ja</span>
|
||||||
|
<span v-else-if="row.chatEnabled === false" class="text-rose-600">Nein</span>
|
||||||
|
<span v-else class="text-gray-500">–</span>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { useNow } from "@vueuse/core";
|
||||||
|
import { workingMinutes } from "~/utils/staffTimeSpans";
|
||||||
|
|
||||||
const profileStore = useProfileStore();
|
const profileStore = useProfileStore();
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const staffTime = useStaffTime()
|
const staffTime = useStaffTime()
|
||||||
|
|
||||||
const runningTimeInfo = ref({})
|
const runningTimeInfo = ref({})
|
||||||
|
const clock = useNow({ interval: 1000 })
|
||||||
|
const workingDuration = computed(() => {
|
||||||
|
const minutes = workingMinutes(runningTimeInfo.value, clock.value.getTime())
|
||||||
|
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')} h`
|
||||||
|
})
|
||||||
|
|
||||||
const projects = ref([])
|
const projects = ref([])
|
||||||
const platform = ref("default")
|
const platform = ref("default")
|
||||||
@@ -51,9 +58,12 @@ const stopStartedTime = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="runningTimeInfo.started_at">
|
<div v-if="runningTimeInfo.started_at">
|
||||||
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
|
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
|
||||||
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
|
<p>Arbeitszeit: {{ workingDuration }}</p>
|
||||||
|
<p v-if="runningTimeInfo.paused">In Pause</p>
|
||||||
|
<UButton v-if="runningTimeInfo.model === 'spans-v2'" class="mt-2" variant="soft" :to="{ path: '/v2/time', query: { user_id: runningTimeInfo.user_id } }">Anwesenheit bearbeiten</UButton>
|
||||||
|
|
||||||
<UFormField
|
<UFormField
|
||||||
|
v-if="runningTimeInfo.model !== 'spans-v2'"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
label="Notizen:"
|
label="Notizen:"
|
||||||
>
|
>
|
||||||
@@ -62,6 +72,7 @@ const stopStartedTime = async () => {
|
|||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
<UFormField
|
<UFormField
|
||||||
|
v-if="runningTimeInfo.model !== 'spans-v2'"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
label="Projekt:"
|
label="Projekt:"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { useNow } from "@vueuse/core";
|
||||||
|
import { workingMinutes } from "~/utils/staffTimeSpans";
|
||||||
|
|
||||||
const profileStore = useProfileStore();
|
const profileStore = useProfileStore();
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const staffTime = useStaffTime()
|
const staffTime = useStaffTime()
|
||||||
|
|
||||||
const runningTimeInfo = ref({})
|
const runningTimeInfo = ref({})
|
||||||
|
const clock = useNow({ interval: 1000 })
|
||||||
|
const workingDuration = computed(() => {
|
||||||
|
const minutes = workingMinutes(runningTimeInfo.value, clock.value.getTime())
|
||||||
|
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')} h`
|
||||||
|
})
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
const rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
|
const rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
|
||||||
@@ -47,9 +54,12 @@ const stopStartedTime = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="runningTimeInfo.started_at">
|
<div v-if="runningTimeInfo.started_at">
|
||||||
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
|
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
|
||||||
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
|
<p>Arbeitszeit: {{ workingDuration }}</p>
|
||||||
|
<p v-if="runningTimeInfo.paused">In Pause</p>
|
||||||
|
<UButton v-if="runningTimeInfo.model === 'spans-v2'" class="mt-2" variant="soft" :to="{ path: '/v2/time', query: { user_id: runningTimeInfo.user_id } }">Anwesenheit bearbeiten</UButton>
|
||||||
|
|
||||||
<UFormField
|
<UFormField
|
||||||
|
v-if="runningTimeInfo.model !== 'spans-v2'"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
label="Notizen:"
|
label="Notizen:"
|
||||||
>
|
>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user