17 Commits

Author SHA1 Message Date
a2a1151ccb Merge pull request 'dev' (#241) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m29s
Reviewed-on: #241
2026-10-07 17:11:45 +00:00
15e480ee9a fix(build): isolate time tests by Docker build context
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m0s
Build and Release FEDEO / build-and-release (push) Successful in 6m4s
2026-10-07 19:02:23 +02:00
c2c6ca8fe9 fix(documents): include foreign country in recipient address
Some checks failed
Build and Release FEDEO / verify (push) Successful in 1m3s
Build and Release FEDEO / build-and-release (push) Failing after 1m26s
2026-10-07 18:52:57 +02:00
ac00d2187e feat(accounting): edit cost centres from finalized outgoing documents 2026-10-07 18:50:38 +02:00
29a9ec2242 feat(communication): add chat reactions emojis and images 2026-10-03 17:56:07 +02:00
59538f46b7 fix(communication): enable scrolling in collaboration management 2026-10-03 17:03:03 +02:00
677ae0f4fb feat(time): add span-based attendance tracking under 2.0 2026-10-03 15:55:45 +02:00
4c67ddd908 feat(notifications): add per-tenant notification preferences 2026-10-03 14:50:38 +02:00
efa6ea90a2 fix(communication): display project chat state and refresh room visibility 2026-10-03 14:38:39 +02:00
fdcb9bfc1d feat(accounting): allow cancelling serial invoice executions 2026-10-02 18:48:06 +02:00
daaed01d02 Merge pull request 'dev' (#240) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #240
2026-10-02 16:30:02 +00:00
7e6c2fbac5 feat(communication): add room visibility and project chat controls
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m27s
2026-10-02 18:27:02 +02:00
0887863981 feat(communication): integrate collaboration rooms into normal chat 2026-10-02 18:12:03 +02:00
6ff9971ff9 fix(push-admin): show test push delivery errors 2026-10-02 18:03:11 +02:00
e7d6c482ad Merge pull request 'dev' (#239) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m13s
Reviewed-on: #239
2026-10-02 15:17:16 +00:00
1885bfc521 feat(documents): allow archiving inbox documents
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 5m56s
2026-10-02 17:12:05 +02:00
09a002d600 fix(collaboration): configure federation from existing deployment settings 2026-10-02 16:58:57 +02:00
99 changed files with 4488 additions and 437 deletions

View File

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

View File

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

View File

@@ -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")
);

View 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');

View 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();

View 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)
);

View File

@@ -526,6 +526,34 @@
"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
}
]
}

View 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`) }))

View File

@@ -50,10 +50,10 @@ export const collaborationMembers = pgTable("collaboration_members", {
export const collaborationMessages = pgTable("collaboration_messages", {
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
seq: bigint("seq", { mode: "number" }).notNull(), principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id),
clientId: uuid("client_id").notNull(), body: text("body").notNull(), 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),
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", {
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
targetPrincipalId: uuid("target_principal_id").notNull().references(() => collaborationPrincipals.id), tokenHash: text("token_hash").notNull().unique(),

View File

@@ -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] }) }))

View File

@@ -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 { communicationRooms } from "./communication_rooms"
@@ -20,6 +20,7 @@ export const communicationMessages = pgTable(
.notNull()
.references(() => authUsers.id),
body: text("body").notNull(),
image: jsonb("image"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),

View File

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

View File

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

View File

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

View File

@@ -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] })]);

View File

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

View File

@@ -802,7 +802,7 @@ export const accountingTools: McpTool[] = [
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["draft", "completed", "error"] },
status: { type: "string", enum: ["generating", "draft", "processing", "cancelling", "cancelled", "completed", "error"] },
...paginationProperties,
},
},

View 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)
}

View 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 }
}

View File

@@ -1,3 +1,4 @@
import { chatContent, imageMetadata } from "../chat-content"
import { generateKeyPairSync, randomUUID } from "node:crypto"
import { sql } from "drizzle-orm"
import type { FastifyInstance } from "fastify"
@@ -7,6 +8,7 @@ import { FederationKey, Headers, instanceUrl, safeTransport, signedHeaders, Tran
type Options = {
apiUrl: string; enabled: boolean;
disabledReason?: string;
seal: (value: string) => any; unseal: (value: any) => string;
transport?: Transport;
}
@@ -22,7 +24,7 @@ export class FederationService {
this.transport = options.transport || safeTransport
}
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)
}
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()`)
}
}
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) })) }
}
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 *`)
if (JSON.stringify(job.payload) !== JSON.stringify(entry.payload)) {
// JSONB key order differs: compare the actual command values instead of its serialization.
if (job.payload.input?.text !== entry.payload.input?.text || job.payload.actorId !== entry.payload.actorId || job.payload.roomId !== entry.payload.roomId || job.payload.input?.revision !== entry.payload.input?.revision) 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
}
@@ -222,6 +226,9 @@ export class FederationService {
if (action === "room.get") return this.service.room(principal, room.id)
if (action === "messages.get") return this.service.messages(principal, room.id, request.input)
if (action === "messages.send") return this.service.send(principal, room.id, request.input)
if (action === "image.get") return this.service.image(principal, room.id, request.input)
if (action === "reactions.get") return this.service.reactions(principal, room.id, request.input)
if (action === "reactions.set") return this.service.react(principal, room.id, request.input)
if (action === "members.get") return { members: await this.service.members(principal, room.id) }
if (action === "read") return this.service.markRead(principal, room.id, request.input?.seq)
fail("Unbekannte Föderationsaktion")
@@ -299,7 +306,7 @@ export class FederationService {
}
async cache(binding: any, messages: any[], snapshot?: any) {
if (!Array.isArray(messages) || messages.length > 50) fail("Ungültige Nachrichtenantwort", 502)
const projected = messages.map(m => ({ id: uuid(m.id), clientId: uuid(m.clientId), seq: sequence(m.seq), body: text(m.body, 10000),
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',
own: m.own === true, createdAt: new Date(m.createdAt).toISOString() }))
await this.service.db.transaction(async db => {
@@ -325,8 +332,9 @@ export class FederationService {
await this.allowed(ctx.tenantId, binding.origin)
if (action === "send") {
if (!binding.snapshot?.canSend) fail("Unterraum ist nicht zum Schreiben freigegeben", 403)
const content = await chatContent(input)
const command = { actorId: ctx.principal.id, roomId: binding.room_id,
input: { clientId: uuid(input.clientId), text: 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,
action: 'messages.send', payload: command, bindingId: binding.id })
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)
return { found: true, result: message?.payload || this.pendingMessage(currentJob) }
}
if (["image", "reactions", "react"].includes(action)) {
const rpc = action === "image" ? "image.get" : action === "react" ? "reactions.set" : "reactions.get"
try {
const result = await this.call(ctx.tenantId, binding.origin, rpc, { actorId: ctx.principal.id, roomId: binding.room_id,
input: { ...input, revision: Number(binding.revision) } })
return { found: true, result }
} catch (error: any) {
if ([401, 403, 404, 410].includes(error.statusCode)) {
// A missing image/message is not by itself evidence of revoked room access.
await this.sync(ctx, binding)
}
throw error
}
}
const detail = await this.sync(ctx, binding)
binding = await this.binding(ctx, id)
if (action === "room") return { found: true, result: detail }
@@ -367,7 +389,7 @@ export class FederationService {
return { found: true, result: { messages, hasMore: page.length === 50, offline: detail.offline } }
}
pendingMessage(job: any) {
return { id: job.payload.input.clientId, clientId: job.payload.input.clientId, seq: null, body: job.payload.input.text,
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' }
}
async deliver(job: any) {
@@ -432,12 +454,12 @@ export class FederationService {
export async function federationRoutes(server: FastifyInstance, { federation }: { federation: FederationService }) {
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
try { done(null, JSON.parse(body)) } catch { done(Object.assign(new Error('Ungültiges JSON'), { statusCode: 400 })) }
})
server.get('/.well-known/fedeo-federation', async () => federation.discovery())
server.post('/federation/v1/rpc', { bodyLimit: 128 * 1024 }, async (req: any, reply) => {
server.post('/federation/v1/rpc', { bodyLimit: 1024 * 1024 }, async (req: any, reply) => {
reply.header('Cache-Control','no-store')
// Duplicate signature-bearing fields are rejected rather than silently combined.
const names = req.raw.rawHeaders.filter((_: string, index: number) => index % 2 === 0).map((name: string) => name.toLowerCase())

View File

@@ -4,16 +4,18 @@ import { sendMail } from "../../utils/mailer"
import { CollaborationService } from "./service"
import { FederationService } from "./federation"
import { encrypt, decrypt } from "../../utils/crypt"
import { collaborationConfig } from "./config"
export function collaborationRuntime(server: FastifyInstance) {
const apiUrl = String(process.env.FEDEO_FEDERATION_URL || secrets.API_BASE_URL || "").replace(/\/$/, "")
const publicUrl = String(process.env.FEDEO_PUBLIC_URL || (apiUrl ? new URL(apiUrl).origin : "")).replace(/\/$/, "")
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
...process.env, API_BASE_URL: secrets.API_BASE_URL, ENCRYPTION_KEY: secrets.ENCRYPTION_KEY,
})
const service = new CollaborationService(server.db, {
publicUrl,
guestPepper: () => secrets.ENCRYPTION_KEY || "",
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.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
let timer: ReturnType<typeof setInterval>

View File

@@ -3,6 +3,8 @@ import { sql } from "drizzle-orm"
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content"
type Db = Pick<NodePgDatabase<any>, "execute" | "transaction">
export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows
export type CollaborationOptions = {
@@ -172,7 +174,7 @@ export class CollaborationService {
const room = await this.roomRow(this.db, roomId)
const member = await this.membership(this.db, room, principal)
const after = sequence(query.after), before = sequence(query.before)
const result = await rows(this.db, sql`SELECT m.id,m.client_id AS "clientId",m.seq::float8 AS seq,m.body,m.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
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}
@@ -182,22 +184,45 @@ export class CollaborationService {
}
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) => {
const room = await this.roomRow(db, roomId, true)
const member = await this.membership(db, room, principal, true)
if (principal.kind === "remote" && sequence(input.revision) !== Number(member.revision)) fail("Teilnahmerechte haben sich geändert", 409)
const [existing] = await rows(db, sql`SELECT id,client_id AS "clientId",seq::float8 AS seq,body,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}`)
if (existing) {
if (existing.body !== body) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409)
return { ...existing, own: true, senderName: principal.display_name, senderKind: principal.kind }
if (existing.body !== body || (existing.image?.dataUrl || null) !== (image?.dataUrl || null) || (existing.image?.name || null) !== (image?.name || null)) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409)
return { ...existing, image: imageMetadata(existing.image), own: true, senderName: principal.display_name, senderKind: principal.kind }
}
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)
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body}) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image)
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
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))
})
}

View File

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

View File

@@ -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) {
return await this.server.db
.select()
@@ -259,7 +295,7 @@ export class NotificationService {
const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled
return true
return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
})
}

View File

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

View File

@@ -184,6 +184,29 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
if (!file) throw scannerError(404, "Dokument nicht gefunden.")
return currentAssignments(db, tenantId, file)
}
const archiveDocument = async (tenantId: number, userId: string, fileId: string) => db.transaction(async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, tenantId)).for("share")
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
// Use the same lock order as filing so both actions cannot succeed concurrently.
const [entry] = await tx.select({ filedAt: scannerInboxDocuments.filedAt }).from(scannerInboxDocuments)
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId))).for("update")
if (!entry) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
if (entry.filedAt) throw scannerError(409, "Dieses Dokument wurde bereits abgelegt. Bitte den Eingang aktualisieren.")
const [file] = await tx.select({ archived: files.archived }).from(files)
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId))).for("update")
if (!file) throw scannerError(404, "Dokument nicht gefunden.")
if (!file.archived) {
await tx.update(files).set({ archived: true, updatedAt: new Date(), updatedBy: userId })
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
}
// Keep the receipt for scanner retries; invalidate any analysis already in flight.
await tx.update(scannerInboxDocuments).set({ suggestionClaim: null,
suggestionStatus: sql`CASE WHEN ${scannerInboxDocuments.suggestionStatus} = 'processing' THEN 'pending' ELSE ${scannerInboxDocuments.suggestionStatus} END` })
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
return { id: fileId, archived: true }
})
const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string, assignments: FileAssignments = {}) => db.transaction(async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, tenantId)).for("share")
@@ -277,7 +300,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
}
}
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), fileDocument, probe, accept }
getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept }
}
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>

View File

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

View File

@@ -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.' });
}
});
}

View 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;
}

View 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 };
});
}
}

View 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 } };
}
}

View File

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

View File

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

View File

@@ -9,11 +9,13 @@ import {
communicationRoomMembers,
communicationRoomReads,
communicationRooms,
communicationHiddenRooms,
notificationsItems,
projects,
} from "../../db/schema"
import { NotificationService, UserDirectory } from "../modules/notification.service"
import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access"
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../modules/chat-content"
type ChatUser = {
userId: string
@@ -159,7 +161,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
const conditions = [eq(communicationMessages.roomId, roomId)]
if (afterId > 0) conditions.push(gt(communicationMessages.id, afterId))
const rows = await server.db.select().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))
.orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id))
.limit(Math.min(Math.max(limit, 1), 100))
@@ -180,6 +185,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
return orderedRows.map((message) => ({
id: message.id,
body: message.body,
image: imageMetadata(message.image),
sender: message.authorUserId,
senderDisplayName: displayName(authorById.get(message.authorUserId) || {}),
timestamp: message.createdAt.getTime(),
@@ -200,32 +206,34 @@ export default async function communicationRoutes(server: FastifyInstance) {
.from(communicationRoomMembers).where(eq(communicationRoomMembers.roomId, room.id))
const memberIds = new Set(memberRows.map((member) => member.userId))
const normalizedText = message.body.toLowerCase()
const recipients = users.filter((user) => {
if (user.userId === senderId || !memberIds.has(user.userId)) return false
if (room.type === "direct") return true
const recipients = users.filter((user) => user.userId !== senderId && memberIds.has(user.userId))
const isMentioned = (user: ChatUser) => {
const aliases = [displayName(user), user.fullName, user.firstName, user.email]
.filter(Boolean).map((value) => String(value).toLowerCase())
return aliases.some((alias) => normalizedText.includes(`@${alias}`))
})
}
if (!recipients.length) return
const senderName = sender ? displayName(sender) : "FEDEO"
await notifications.trigger({
tenantId,
userIds: recipients.map((recipient) => recipient.userId),
eventType: "communication.message.new",
title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : `${senderName} hat dich erwähnt`,
message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body,
payload: {
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
roomKey: room.key,
roomName: room.name,
roomType: room.type,
messageId: message.id,
mentioned: room.type !== "direct",
direct: room.type === "direct",
},
channels: ["inapp", "push"],
})
for (const eventType of ["communication.message.direct", "communication.message.mentioned", "communication.message.room"]) {
const targets = recipients.filter((user) => (room.type === "direct" ? "communication.message.direct" : isMentioned(user) ? "communication.message.mentioned" : "communication.message.room") === eventType)
if (!targets.length) continue
await notifications.trigger({
tenantId,
userIds: targets.map((recipient) => recipient.userId),
eventType,
title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : eventType === "communication.message.mentioned" ? `${senderName} hat dich erwähnt` : `Neue Nachricht in ${room.name}`,
message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body,
payload: {
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
roomKey: room.key,
roomName: room.name,
roomType: room.type,
messageId: message.id,
mentioned: eventType === "communication.message.mentioned",
direct: room.type === "direct",
},
})
}
} catch (err) {
server.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden")
}
@@ -244,6 +252,28 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" }))
server.get("/communication/chat/room-preferences", async (req: any) => {
const tenantId = await requireTenant(req)
const hidden = await server.db.select({ key: communicationHiddenRooms.roomKey }).from(communicationHiddenRooms)
.where(and(eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id)))
return { hiddenRoomKeys: hidden.map(room => room.key) }
})
server.put("/communication/chat/room-preferences", async (req: any, reply) => {
const tenantId = await requireTenant(req)
const { roomKey, hidden } = req.body || {}
if (typeof hidden !== "boolean" || typeof roomKey !== "string" || roomKey.length > 200
|| !/^(?:[a-z0-9._=-]+|collaboration:[a-f0-9-]{36})$/.test(roomKey)) {
return reply.code(400).send({ error: "Ungültige Raumeinstellung" })
}
const value = { tenantId, userId: req.user.user_id, roomKey }
if (hidden) await server.db.insert(communicationHiddenRooms).values(value).onConflictDoNothing()
else await server.db.delete(communicationHiddenRooms).where(and(
eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id),
eq(communicationHiddenRooms.roomKey, roomKey)))
return { roomKey, hidden }
})
server.get("/communication/chat/users", async (req: any) => {
const tenantId = await requireTenant(req)
return { users: (await tenantUsers(tenantId)).map((user) => ({
@@ -284,6 +314,14 @@ export default async function communicationRoutes(server: FastifyInstance) {
return room
})
if (!created) return reply.code(409).send({ error: "Dieser Raumschlüssel ist bereits vergeben" })
const recipients = userIds.filter((id) => id !== req.user.user_id)
if (recipients.length) {
try {
await notifications.trigger({ tenantId, userIds: recipients, eventType: "communication.room.invited",
title: "Neuer Chatraum", message: `Du wurdest dem Raum „${created.name}“ hinzugefügt.`,
payload: { link: `/communication/chat?room=${encodeURIComponent(created.key)}` } })
} catch (err) { server.log.error({ err }, "Raum-Benachrichtigung fehlgeschlagen") }
}
return { ...roomToApi(created), alreadyExisted: false }
})
@@ -433,17 +471,33 @@ export default async function communicationRoutes(server: FastifyInstance) {
return { ...roomToApi(room), messages, nextId: messages[messages.length - 1]?.id || afterId }
})
server.post("/communication/chat/rooms/:roomKey/messages", async (req: any, reply) => {
server.post("/communication/chat/rooms/:roomKey/messages", async (req: any) => {
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
const body = String(req.body?.text || "").trim()
if (!body) return reply.code(400).send({ error: "Nachricht darf nicht leer sein" })
if (body.length > 10_000) return reply.code(400).send({ error: "Nachricht ist zu lang" })
const { body, image } = await chatContent(req.body)
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()
await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body })
return { id: created.id, body: created.body, sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true }
await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body: body || "📷 Bild" })
return { id: created.id, body: created.body, image: imageMetadata(created.image), sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true }
})
server.get("/communication/chat/rooms/:roomKey/image", async (req: any, reply) => {
reply.header("Cache-Control", "no-store")
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
return chatImage(server.db, "communication", room.id, req.query.messageId)
})
server.get("/communication/chat/rooms/:roomKey/reactions", async (req: any, reply) => {
reply.header("Cache-Control", "no-store")
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
return chatReactions(server.db, "communication", room.id, req.user.user_id, req.query.ids)
})
server.put("/communication/chat/rooms/:roomKey/reactions", async (req: any) => {
const tenantId = await requireTenant(req)
const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey)
return setChatReaction(server.db, "communication", room.id, req.user.user_id, req.body)
})
server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => {

View File

@@ -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 { and, desc, eq } from "drizzle-orm";
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
@@ -8,7 +11,8 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
async (req, reply) => {
try {
// 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,
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`);
}
const timeService = new TimeService(server.db);
const actor = { tenant: device.tenant, user: profile.user_id, trusted: true };
if (profile.user_id && await timeService.active(actor, profile.user_id)) {
return await server.db.transaction(async tx => {
await timeService.lock(tx, actor, profile.user_id);
const scoped = new TimeService(tx);
const at = timestamp && timestamp > 0 ? new Date(timestamp * 1000).toISOString() : new Date().toISOString();
const key = `rfid:${digest({ terminal_id, rfid_id, event: request_id || timestamp || randomUUID() })}`;
const [cached] = await rows(tx, sql`SELECT response FROM staff_time_v2_requests WHERE tenant_id=${device.tenant} AND user_id=${profile.user_id}::uuid AND request_id=${key}`);
if (cached) return cached.response;
const current = await scoped.current(actor, profile.user_id);
return scoped.mutate(actor, profile.user_id, key, current
? { action: 'stop', id: current.id, version: current.version, at }
: { action: 'start', at }, 'terminal');
});
}
// 4. Letztes Event suchen (für Status-Toggle Work Start/End)
const lastEvent = await server.db
.select()
.from(stafftimeevents)
.where(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))
.limit(1)
.then(rows => rows[0]);

View File

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

View File

@@ -20,7 +20,7 @@ import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys, files} from "../../db/schema";
import {and, eq, isNull, not} from "drizzle-orm";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
import { s3 } from "../utils/s3";
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)
})
server.post('/functions/serial/cancel/:execution_id', {
schema: { params: { type: 'object', required: ['execution_id'], properties: { execution_id: { type: 'string', format: 'uuid' } } } },
}, async (req) => {
const { execution_id } = req.params as { execution_id: string }
return cancelManualGeneration(server, execution_id, req.user.tenant_id)
})
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
const {execution_id} = req.params as { execution_id: string }
//@ts-ignore

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
import { projectChatSettingError } from "../../modules/communication-access"
import { FastifyInstance } from "fastify"
import {
eq,
@@ -942,6 +943,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = config.table;
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
if (resource === "projects") {
const error = projectChatSettingError(req, createData)
if (error) return reply.code(error.statusCode).send({ error: error.error })
}
if (resource === "members") {
createData = normalizeMemberPayload(createData)
@@ -1095,6 +1100,11 @@ export default async function resourceRoutes(server: FastifyInstance) {
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 }
delete data.updatedAt
delete data.updated_at

View 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);
});
}

View File

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

View File

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

View File

@@ -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]))
}
// Span children have no tenant column; include them through their scoped parent IDs.
const timeSpanIds = collectIds(tables.staff_time_spans || [], "id")
if (timeSpanIds.length) {
for (const table of ["staff_time_span_breaks", "staff_time_span_projects", "staff_time_span_history"]) {
if (columnsByTable.has(table)) {
addRows(tables, table, await loadRows(client, table, "span_id = any($1::uuid[])", [timeSpanIds]))
}
}
}
const legacyTimeIds = collectIds(tables.staff_time_entries || [], "id")
if (legacyTimeIds.length && columnsByTable.has("staff_time_entry_connects")) {
addRows(tables, "staff_time_entry_connects", await loadRows(client, "staff_time_entry_connects", "time_entry_id = any($1::uuid[])", [legacyTimeIds]))
}
const communicationRoomIds = collectIds(tables.communication_rooms || [], "id")
if (communicationRoomIds.length) {
addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds]))

View File

@@ -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() }
})

View 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() }
})

View 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)
})

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
let suffix = column.primary ? " PRIMARY KEY" : ""
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
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}`
})
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 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"))
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"))
}
return { db, pool, close: async () => {

View 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'])
})

View File

@@ -527,6 +527,61 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
assert.equal((await fileInto(id)).statusCode, 404)
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 pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
assert.equal((await send("maintenance.pdf")).statusCode, 423)

View 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 })
})

View 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);
}
});

View File

@@ -125,7 +125,7 @@ services:
API_BASE_URL: ${API_BASE_URL}
FEDEO_PUBLIC_URL: ${FEDEO_PUBLIC_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_SECRET_ID: ${GOCARDLESS_SECRET_ID}
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}

View File

@@ -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)
- [MinIO-Umstellung: ausführbarer Pilot](./minio-umstellung-pilot.md)
- [Konzept für föderierte Chats und externe Projektteilnehmer](./chat-foederation-konzept.md)
- [Zeiterfassung 2.0 und Datenübernahme](./zeiterfassung-2.0.md)

View File

@@ -1,6 +1,6 @@
# 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
@@ -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 → 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.
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
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.
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.
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.
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. 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. 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.
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.
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
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.
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
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 \
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 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.

View 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.

View File

@@ -12,7 +12,13 @@ Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scan
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.
@@ -49,6 +55,7 @@ Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit
- 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/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/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

View File

@@ -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.
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.
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.

83
docs/zeiterfassung-2.0.md Normal file
View 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
```

View File

@@ -13,7 +13,7 @@ ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
RUN npm run build
RUN node --test tests/time-v2.test.cjs && npm run build
# --- Stage 2: Runtime ---
FROM node:22-alpine AS runner

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,7 +1,7 @@
<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 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)
let generation = 0, timer, polling = false, pending = null
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) {
error.value = messageError(e)
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')
}
}
@@ -32,8 +32,8 @@ async function read(id, version) {
}
async function load() {
const version = ++generation, id = props.roomId
room.value = null; messages.value = []; members.value = []; draft.value = ''; error.value = ''; pending = null
loading.value = true
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null
loading.value = true; sending.value = false; olderLoading.value = false
try {
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
@@ -75,18 +75,18 @@ async function older() {
finally { olderLoading.value = false }
}
async function send() {
const text = draft.value.trim()
if (!text || sending.value || !room.value?.canSend) return
const text = draft.value.trim(), image = draftImage.value
if ((!text && !image) || sending.value || !room.value?.canSend) return
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 = ''
try {
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
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)
} 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' })
watch(() => props.roomId, load, { immediate: true })
@@ -95,9 +95,9 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
</script>
<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">
<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="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
</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.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
</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>
<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>
<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 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="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>
<ChatReactionBar :reactions="reactions[message.id] || []" :busy="busy" :disabled="!room?.canSend || room?.offline || !!message.pending || !!message.failed" @react="react(message.id, $event)" />
</div>
</div>
</ChatReactions>
</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">
<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>
<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" />
</section>
</template>

View File

@@ -478,6 +478,12 @@ const links = computed(() => {
icon: "i-heroicons-user-group",
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 ? [{
label: "Mitarbeiter",
defaultOpen: false,

View 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>

View File

@@ -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>

View File

@@ -1,11 +1,18 @@
<script setup>
import dayjs from "dayjs";
import { useNow } from "@vueuse/core";
import { workingMinutes } from "~/utils/staffTimeSpans";
const profileStore = useProfileStore();
const toast = useToast()
const staffTime = useStaffTime()
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 platform = ref("default")
@@ -51,9 +58,12 @@ const stopStartedTime = async () => {
<template>
<div v-if="runningTimeInfo.started_at">
<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
v-if="runningTimeInfo.model !== 'spans-v2'"
class="mt-2"
label="Notizen:"
>
@@ -62,6 +72,7 @@ const stopStartedTime = async () => {
/>
</UFormField>
<UFormField
v-if="runningTimeInfo.model !== 'spans-v2'"
class="mt-2"
label="Projekt:"
>

View File

@@ -1,11 +1,18 @@
<script setup>
import dayjs from "dayjs";
import { useNow } from "@vueuse/core";
import { workingMinutes } from "~/utils/staffTimeSpans";
const profileStore = useProfileStore();
const toast = useToast()
const staffTime = useStaffTime()
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 rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
@@ -47,9 +54,12 @@ const stopStartedTime = async () => {
<template>
<div v-if="runningTimeInfo.started_at">
<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
v-if="runningTimeInfo.model !== 'spans-v2'"
class="mt-2"
label="Notizen:"
>

View File

@@ -7,7 +7,7 @@ const setupPage = async () => {
const checks = await Promise.all(profiles.map(async (profile) => {
try {
const spans = await useNuxtApp().$api(`/api/staff/time/spans?targetUserId=${profile.user_id || profile.id}`)
const openSpan = (spans || []).find((s) => !s.endedAt && s.type === "work")
const openSpan = (spans || []).find((s) => !s.endedAt && ["work", "pause"].includes(s.type))
if (openSpan) return { profile: profile.id }
} catch (e) {
return null

View File

@@ -1,3 +1,4 @@
import { staffTimeRows } from '~/utils/staffTimeSpans'
import { defineStore } from 'pinia'
import { useAuthStore } from '~/stores/auth'
@@ -14,21 +15,7 @@ export const useStaffTime = () => {
const params = new URLSearchParams({ from, to, targetUserId })
try {
const spans = await $api(`/api/staff/time/spans?${params.toString()}`)
return (spans || []).map((span: any) => {
const start = $dayjs(span.startedAt)
const end = span.endedAt ? $dayjs(span.endedAt) : $dayjs()
return {
id: span.sourceEventIds && span.sourceEventIds.length > 0 ? span.sourceEventIds[0] : null,
eventIds: span.sourceEventIds || [],
state: span.status,
started_at: span.startedAt,
stopped_at: span.endedAt,
duration_minutes: end.diff(start, 'minute'),
user_id: targetUserId,
type: span.type,
description: span.description || span.payload?.description || ''
}
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf())
return staffTimeRows(spans || [], targetUserId)
} catch (error) {
console.error("Fehler beim Laden:", error)
return []

View File

@@ -1,219 +1,283 @@
<script setup>
const toast = useToast()
const { $api } = useNuxtApp()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const baseRooms = ref([])
const projectRooms = ref([])
const directRooms = ref([])
const unreadRooms = ref({})
const messages = ref([])
const members = ref([])
const draft = ref("")
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
const loading = ref(true)
const roomLoading = ref(false)
const sending = ref(false)
const creating = ref(false)
const showRoomForm = ref(false)
const messagesViewport = ref(null)
const roomForm = reactive({ name: "", key: "", topic: "" })
let pollTimer = null
let pollBusy = false
let syncAfterId = 0
const normalizeRoomKey = (value) => String(value || "")
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9._=-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._=-]+|[._=-]+$/g, "")
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null)
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
const activeRoomKey = ref(requestedRoom())
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
const showRoomForm = ref(false), messagesViewport = ref(null)
const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('')
const preferencesReady = ref(false)
const roomForm = reactive({ name: '', key: '', topic: '' })
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
const hasSession = computed(() => !!auth.user && !!auth.activeTenant)
let pollTimer, listTimer, pollBusy = false, mounted = false, disposed = false
let scopeVersion = 0, roomVersion = 0, collaborationRequest = 0, nativeListRequest = 0, syncAfterId = 0
const currentScope = version => !disposed && version === scopeVersion
const selection = () => ({ scope: scopeVersion, version: roomVersion, key: activeRoomKey.value })
const currentSelection = request => currentScope(request.scope) && request.version === roomVersion && request.key === activeRoomKey.value
const errorMessage = error => error?.data?.message || error?.data?.error || 'Die Anfrage konnte nicht ausgeführt werden.'
// Colons cannot occur in normalized internal room keys, so both room types stay unambiguous.
const collaborationKey = id => `collaboration:${id}`
const nativeEndpoint = (key, suffix = '') => `/api/communication/chat/rooms/${encodeURIComponent(key)}${suffix}`
const normalizeRoomKey = value => String(value || '')
.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss')
.replace(/[^a-z0-9._=-]+/g, '_').replace(/_+/g, '_').replace(/^[._=-]+|[._=-]+$/g, '')
const roomKeyPreview = computed(() => normalizeRoomKey(roomForm.key || roomForm.name))
const decoratedRooms = computed(() => [
...baseRooms.value.map((room) => ({ ...room, group: "Räume", icon: "i-heroicons-chat-bubble-left-right" })),
...projectRooms.value.map((room) => ({ ...room, group: "Projekte", icon: "i-heroicons-briefcase" })),
...directRooms.value.map((room) => ({ ...room, group: "Direktnachrichten", icon: "i-heroicons-user-circle" }))
].map((room) => ({ ...room, unread: unreadRooms.value[room.key]?.count || 0 })))
const groupedRooms = computed(() => ["Räume", "Projekte", "Direktnachrichten"]
.map((label) => ({ label, rooms: decoratedRooms.value.filter((room) => room.group === label) }))
.filter((group) => group.rooms.length))
const activeRoom = computed(() => decoratedRooms.value.find((room) => room.key === activeRoomKey.value) || null)
const roomEndpoint = computed(() => `/api/communication/chat/rooms/${encodeURIComponent(activeRoomKey.value)}`)
...[
...baseRooms.value.map(room => ({ ...room, group: 'Räume', icon: 'i-heroicons-chat-bubble-left-right' })),
...projectRooms.value.map(room => ({ ...room, group: 'Projekte', icon: 'i-heroicons-briefcase' })),
...directRooms.value.map(room => ({ ...room, group: 'Direktnachrichten', icon: 'i-heroicons-user-circle' }))
].map(room => ({ ...room, source: 'internal', unread: unreadRooms.value[room.key]?.count || 0 })),
...collaborationRooms.value.map(room => ({ ...room, key: collaborationKey(room.id), source: 'collaboration', exists: true,
group: 'Zusammenarbeit', icon: 'i-heroicons-user-group', unread: room.unread || 0 }))
])
const visibleRooms = computed(() => decoratedRooms.value.filter(room => !hiddenRoomKeys.value.includes(room.key)))
const hiddenRooms = computed(() => decoratedRooms.value.filter(room => hiddenRoomKeys.value.includes(room.key)))
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
.map(label => ({ label, rooms: visibleRooms.value.filter(room => room.group === label) }))
.filter(group => group.rooms.length))
const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null)
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
const isInternal = computed(() => activeRoom.value?.source === 'internal')
const mergeMessages = (incoming) => {
const byId = new Map(messages.value.map((message) => [message.id, message]))
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; syncAfterId = 0; roomLoading.value = false; sending.value = false }
function mergeMessages(incoming) {
const byId = new Map(messages.value.map(message => [message.id, message]))
for (const message of incoming || []) byId.set(message.id, message)
messages.value = Array.from(byId.values()).sort((a, b) => Number(a.id) - Number(b.id))
syncAfterId = Number(messages.value.at(-1)?.id || syncAfterId)
messages.value = [...byId.values()].sort((a, b) => Number(a.id) - Number(b.id))
syncAfterId = Number(messages.value.at(-1)?.id || 0)
}
const scrollToBottom = async () => {
const scrollToBottom = async request => {
await nextTick()
if (messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
if (currentSelection(request) && messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
}
const loadUnread = async () => {
async function loadUnread(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
try {
unreadRooms.value = (await $api("/api/communication/chat/unread")).rooms || {}
} catch {
unreadRooms.value = {}
}
const result = await $api('/api/communication/chat/unread')
if (currentScope(scope)) unreadRooms.value = result.rooms || {}
} catch { /* A count failure must not interrupt an open conversation. */ }
}
const loadRoomLists = async () => {
const [rooms, projects, directs] = await Promise.all([
$api("/api/communication/chat/rooms"),
$api("/api/communication/chat/project-rooms"),
$api("/api/communication/chat/direct-rooms")
])
baseRooms.value = rooms.rooms || []
projectRooms.value = projects.rooms || []
directRooms.value = directs.rooms || []
}
const markRead = async () => {
if (!activeRoom.value?.exists) return
async function loadRoomLists(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
const request = ++nativeListRequest
try {
await $api(`${roomEndpoint.value}/read`, {
method: "POST",
body: { messageId: Number(messages.value.at(-1)?.id || 0) }
})
unreadRooms.value = { ...unreadRooms.value, [activeRoomKey.value]: { count: 0, mentions: 0 } }
} catch {
// Der Lesestatus darf die Unterhaltung nicht blockieren.
}
}
const loadActiveRoom = async () => {
if (!activeRoom.value?.exists) return
roomLoading.value = true
syncAfterId = 0
try {
const [messageResult, memberResult] = await Promise.all([
$api(`${roomEndpoint.value}/messages`),
$api(`${roomEndpoint.value}/members`)
const [rooms, projects, directs] = await Promise.all([
$api('/api/communication/chat/rooms'), $api('/api/communication/chat/project-rooms'), $api('/api/communication/chat/direct-rooms')
])
messages.value = []
mergeMessages(messageResult.messages || [])
members.value = memberResult.members || []
await markRead()
await scrollToBottom()
} catch (error) {
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geladen werden", color: "error" })
} finally {
roomLoading.value = false
}
if (!currentScope(scope) || request !== nativeListRequest) return
baseRooms.value = rooms.rooms || []; projectRooms.value = projects.rooms || []; directRooms.value = directs.rooms || []
listError.value = ''
} catch (error) { if (currentScope(scope) && request === nativeListRequest) listError.value = errorMessage(error) }
}
const provisionRoom = async (room) => {
const endpoint = room.type === "project"
? `/api/communication/chat/project-rooms/${room.projectId}/provision`
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`
const created = await $api(endpoint, { method: "POST" })
await loadRoomLists()
activeRoomKey.value = created.key
await router.replace({ query: { ...route.query, room: created.key } })
}
const selectRoom = async (room) => {
async function loadCollaboration(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
const request = ++collaborationRequest
try {
if (!room.exists) await provisionRoom(room)
else {
activeRoomKey.value = room.key
await router.replace({ query: { ...route.query, room: room.key } })
}
messages.value = []
members.value = []
await loadActiveRoom()
const result = await $api('/api/collaboration')
if (!currentScope(scope) || request !== collaborationRequest) return
// Managed rooms alone never grant chat access. Only explicit memberships enter this list.
collaborationRooms.value = result.rooms || []; invitations.value = result.invitations || []
collaborationError.value = ''
} catch (error) {
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geöffnet werden", color: "error" })
if (!currentScope(scope) || request !== collaborationRequest) return
collaborationError.value = errorMessage(error)
if ([401, 403].includes(error?.statusCode || error?.status)) { collaborationRooms.value = []; invitations.value = [] }
}
}
async function refreshLists() {
const scope = scopeVersion
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
}
function refreshVisibleLists() {
if (!disposed && !document.hidden && !loading.value && hasSession.value) void refreshLists()
}
const createRoom = async () => {
if (!roomForm.name.trim()) return
async function loadRoomPreferences(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
try {
const result = await $api('/api/communication/chat/room-preferences')
if (!currentScope(scope)) return
hiddenRoomKeys.value = result.hiddenRoomKeys || []
preferencesReady.value = true; preferenceError.value = ''
} catch (error) {
if (currentScope(scope)) preferenceError.value = errorMessage(error)
}
}
async function setRoomHidden(room, hidden) {
if (preferenceBusy.value || !preferencesReady.value) return
const scope = scopeVersion
preferenceBusy.value = room.key
try {
await $api('/api/communication/chat/room-preferences', { method: 'PUT', body: { roomKey: room.key, hidden } })
if (!currentScope(scope)) return
hiddenRoomKeys.value = hidden
? [...new Set([...hiddenRoomKeys.value, room.key])]
: hiddenRoomKeys.value.filter(key => key !== room.key)
if (hidden && activeRoomKey.value === room.key) {
++roomVersion; activeRoomKey.value = ''; clearMessages()
const next = visibleRooms.value.find(candidate => candidate.exists)
if (next) await selectRoom(next)
else await router.replace({ query: { ...route.query, room: undefined } })
} else if (!hidden) {
const restored = visibleRooms.value.find(candidate => candidate.key === room.key)
if (restored) await selectRoom(restored)
}
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
finally { if (currentScope(scope)) preferenceBusy.value = '' }
}
async function markRead(request) {
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
const messageId = Number(messages.value.at(-1)?.id || 0)
try {
await $api(nativeEndpoint(request.key, '/read'), { method: 'POST', body: { messageId } })
if (currentSelection(request)) unreadRooms.value = { ...unreadRooms.value, [request.key]: { count: 0, mentions: 0 } }
} catch { /* Read receipts must not interrupt the conversation. */ }
}
async function loadActiveRoom(request = selection()) {
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
roomLoading.value = true
try {
const [result, people] = await Promise.all([
$api(nativeEndpoint(request.key, '/messages')), $api(nativeEndpoint(request.key, '/members'))
])
if (!currentSelection(request)) return
messages.value = []; mergeMessages(result.messages); members.value = people.members || []
await markRead(request); await scrollToBottom(request)
} catch (error) {
if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
} finally { if (currentSelection(request)) roomLoading.value = false }
}
async function selectRoom(room) {
if (hiddenRoomKeys.value.includes(room.key)) return
++roomVersion; activeRoomKey.value = room.key; clearMessages()
let request = selection()
try {
if (room.source !== 'collaboration' && !room.exists) {
const endpoint = room.type === 'project'
? `/api/communication/chat/project-rooms/${room.projectId}/provision`
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`
const created = await $api(endpoint, { method: 'POST' })
if (!currentSelection(request)) return
await loadRoomLists(request.scope)
if (!currentSelection(request)) return
activeRoomKey.value = created.key; request = selection()
}
await router.replace({ query: { ...route.query, room: request.key } })
if (currentSelection(request)) await loadActiveRoom(request)
} catch (error) { if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' }) }
}
async function createRoom() {
if (!roomForm.name.trim() || creating.value) return
const scope = scopeVersion
creating.value = true
try {
const created = await $api("/api/communication/chat/rooms", {
method: "POST",
body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
const created = await $api('/api/communication/chat/rooms', {
method: 'POST', body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
})
Object.assign(roomForm, { name: "", key: "", topic: "" })
showRoomForm.value = false
await loadRoomLists()
await selectRoom(created)
} catch (error) {
toast.add({ title: error?.data?.error || "Chatraum konnte nicht erstellt werden", color: "error" })
} finally {
creating.value = false
}
if (!currentScope(scope)) return
Object.assign(roomForm, { name: '', key: '', topic: '' }); showRoomForm.value = false
await loadRoomLists(scope)
if (currentScope(scope)) await selectRoom(created)
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
finally { if (currentScope(scope)) creating.value = false }
}
const sendMessage = async () => {
const text = draft.value.trim()
if (!text || sending.value || !activeRoom.value?.exists) return
sending.value = true
draft.value = ""
async function respondToInvitation(id, accept) {
if (invitationBusy.value) return
const scope = scopeVersion
invitationBusy.value = id
try {
const message = await $api(`${roomEndpoint.value}/messages`, { method: "POST", body: { text } })
mergeMessages([message])
await markRead()
await scrollToBottom()
} catch (error) {
draft.value = text
toast.add({ title: error?.data?.error || "Nachricht konnte nicht gesendet werden", color: "error" })
} finally {
sending.value = false
}
const result = await $api(`/api/collaboration/invitations/${encodeURIComponent(id)}/${accept ? 'accept' : 'decline'}`, { method: 'POST' })
if (!currentScope(scope)) return
await loadCollaboration(scope)
if (!currentScope(scope)) return
const room = decoratedRooms.value.find(room => room.key === collaborationKey(result.roomId))
if (accept && room) await selectRoom(room)
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
finally { if (currentScope(scope)) invitationBusy.value = '' }
}
const pollMessages = async () => {
if (pollBusy || document.hidden || !activeRoom.value?.exists) return
async function sendMessage() {
const text = draft.value.trim(), image = draftImage.value, request = selection()
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
sending.value = true; draft.value = ''
try {
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
if (!currentSelection(request)) return
draftImage.value = null; mergeMessages([message]); await markRead(request); await scrollToBottom(request)
} catch (error) {
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
} finally { if (currentSelection(request)) sending.value = false }
}
async function pollMessages() {
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
const request = selection()
pollBusy = true
try {
const result = await $api(`${roomEndpoint.value}/sync?afterId=${syncAfterId}`)
if (result.messages?.length) {
mergeMessages(result.messages)
await markRead()
await scrollToBottom()
}
await loadUnread()
} catch {
// Beim nächsten Intervall erneut versuchen.
} finally {
pollBusy = false
}
}
const formatMessageTime = (timestamp) => new Intl.DateTimeFormat("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"
}).format(new Date(timestamp))
onMounted(async () => {
try {
await Promise.all([loadRoomLists(), loadUnread()])
if (!activeRoom.value) activeRoomKey.value = "allgemein"
await loadActiveRoom()
pollTimer = window.setInterval(pollMessages, 2500)
const result = await $api(nativeEndpoint(request.key, `/sync?afterId=${syncAfterId}`))
if (!currentSelection(request)) return
if (result.messages?.length) { mergeMessages(result.messages); await markRead(request); await scrollToBottom(request) }
await loadUnread(request.scope)
} catch (error) {
toast.add({ title: error?.data?.error || "Chat konnte nicht geladen werden", color: "error" })
} finally {
loading.value = false
}
})
if (currentSelection(request) && [401, 403, 404, 410].includes(error?.statusCode || error?.status)) {
++roomVersion; clearMessages(); void loadRoomLists(request.scope)
}
} finally { pollBusy = false }
}
const formatMessageTime = timestamp => new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(timestamp))
async function initialize() {
const scope = ++scopeVersion
++roomVersion; clearMessages()
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false
hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' })
activeRoomKey.value = requestedRoom(); loading.value = true
await Promise.all([refreshLists(), loadRoomPreferences(scope)])
if (!currentScope(scope)) return
loading.value = false
const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? visibleRooms.value.find(room => room.exists) : null)
if (room) await selectRoom(room)
}
watch(session, () => { if (mounted) void initialize() }, { flush: 'sync' })
watch(() => activeRoom.value?.key, (key, previous) => {
if (!key && previous && !loading.value) { ++roomVersion; clearMessages() }
})
watch(() => route.query.room, () => {
if (!mounted || loading.value || requestedRoom() === activeRoomKey.value) return
const room = visibleRooms.value.find(room => room.key === requestedRoom())
if (room) void selectRoom(room)
else { ++roomVersion; activeRoomKey.value = requestedRoom(); clearMessages() }
})
onMounted(() => {
mounted = true; void initialize()
pollTimer = window.setInterval(pollMessages, 2500)
listTimer = window.setInterval(refreshVisibleLists, 15000)
window.addEventListener('focus', refreshVisibleLists)
document.addEventListener('visibilitychange', refreshVisibleLists)
})
onBeforeUnmount(() => {
if (pollTimer) window.clearInterval(pollTimer)
disposed = true; ++scopeVersion; ++roomVersion; clearInterval(pollTimer); clearInterval(listTimer)
window.removeEventListener('focus', refreshVisibleLists)
document.removeEventListener('visibilitychange', refreshVisibleLists)
})
</script>
<template>
<div class="flex min-h-0 flex-1 bg-gray-50 p-4 sm:p-6">
<div class="mx-auto grid h-[calc(100vh-8rem)] w-full max-w-7xl grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm lg:grid-cols-[19rem_minmax(0,1fr)_15rem]">
<div class="flex h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden bg-white">
<div class="grid h-full min-h-0 w-full flex-1 grid-cols-1 grid-rows-[minmax(8rem,30%)_minmax(0,1fr)] overflow-hidden lg:grid-rows-1"
:class="isCollaboration ? 'lg:grid-cols-[19rem_minmax(0,1fr)]' : 'lg:grid-cols-[19rem_minmax(0,1fr)_15rem]'">
<aside class="min-h-0 overflow-y-auto border-r border-gray-200 p-4">
<div class="mb-4 flex items-center justify-between">
<div>
@@ -233,30 +297,67 @@ onBeforeUnmount(() => {
</div>
</form>
<UButton to="/communication/collaboration" icon="i-heroicons-user-group" color="neutral" variant="outline" size="xs" class="mb-4 w-full justify-center">Unterräume und Freigaben</UButton>
<p v-if="listError" role="alert" class="mb-3 text-xs text-red-700">{{ listError }} <button class="underline" @click="loadRoomLists()">Erneut laden</button></p>
<p v-if="collaborationError" role="alert" class="mb-3 text-xs text-red-700">Zusammenarbeit: {{ collaborationError }} <button class="underline" @click="loadCollaboration()">Erneut laden</button></p>
<p v-if="preferenceError" role="alert" class="mb-3 text-xs text-red-700">Raumeinstellungen: {{ preferenceError }} <button class="underline" @click="loadRoomPreferences()">Erneut laden</button></p>
<section v-if="invitations.length" class="mb-5 space-y-2" aria-label="Einladungen">
<h2 class="px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Einladungen</h2>
<div v-for="invitation in invitations" :key="invitation.id" class="rounded-lg border border-primary-200 bg-primary-50 p-3">
<p class="text-sm font-medium">{{ invitation.name }}</p>
<p class="text-xs text-gray-500">{{ invitation.ownerName }} · {{ invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben' }}</p>
<p v-if="invitation.overviewAllowed" class="mt-1 text-xs text-gray-500">Projektübersicht freigegeben</p>
<div class="mt-2 flex gap-2">
<UButton size="xs" :disabled="!!invitationBusy" @click="respondToInvitation(invitation.id, true)">Annehmen</UButton>
<UButton size="xs" variant="ghost" color="neutral" :disabled="!!invitationBusy" @click="respondToInvitation(invitation.id, false)">Ablehnen</UButton>
</div>
</div>
</section>
<div v-if="loading" class="py-8 text-center text-sm text-gray-500">Chat wird geladen …</div>
<div v-else class="space-y-5">
<section v-for="group in groupedRooms" :key="group.label">
<h2 class="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">{{ group.label }}</h2>
<button
<div
v-for="room in group.rooms"
:key="room.key"
type="button"
class="mb-1 flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-gray-100"
class="mb-1 flex w-full items-center rounded-lg transition hover:bg-gray-100"
:class="room.key === activeRoomKey ? 'bg-primary-50 text-primary-700' : 'text-gray-700'"
@click="selectRoom(room)"
>
<UIcon :name="room.icon" class="size-5 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
<span class="block truncate text-xs text-gray-400">{{ room.topic }}</span>
</span>
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left"
@click="selectRoom(room)"
>
<UIcon :name="room.icon" class="size-5 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
<span class="block truncate text-xs text-gray-400">{{ room.source === 'collaboration' ? room.ownerName || 'Projekt-Unterraum' : room.topic }}{{ room.remote ? ' · Extern' : '' }}{{ room.status === 'archived' ? ' · Archiviert' : '' }}</span>
</span>
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
</button>
<UButton icon="i-heroicons-eye-slash" color="neutral" variant="ghost" size="xs" class="mr-1 shrink-0" :aria-label="`${room.name} ausblenden`" title="Für mich ausblenden" :disabled="!preferencesReady || !!preferenceBusy" @click="setRoomHidden(room, true)" />
</div>
</section>
<p v-if="!visibleRooms.length" class="px-2 text-sm text-gray-500">Keine sichtbaren Räume. Du kannst ausgeblendete Räume unten wieder einblenden.</p>
<section v-if="hiddenRooms.length" aria-label="Ausgeblendete Räume">
<button type="button" class="flex w-full items-center justify-between px-2 py-2 text-left text-xs font-semibold text-gray-500" :aria-expanded="showHiddenRooms" @click="showHiddenRooms = !showHiddenRooms">
<span>Ausgeblendete Räume ({{ hiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
</button>
<div v-if="showHiddenRooms" class="space-y-1">
<div v-for="room in hiddenRooms" :key="room.key" class="flex items-center gap-2 px-2 py-1">
<span class="min-w-0 flex-1 truncate text-sm text-gray-500">{{ room.name }}</span>
<UButton icon="i-heroicons-eye" variant="ghost" color="neutral" size="xs" :aria-label="`${room.name} einblenden`" :disabled="!!preferenceBusy" @click="setRoomHidden(room, false)">Einblenden</UButton>
</div>
</div>
</section>
</div>
</aside>
<main class="flex min-h-0 flex-col">
<CollaborationRoom v-if="!loading && isCollaboration" :key="`${session}:${activeRoom.id}`" :room-id="activeRoom.id" :api="$api" embedded @changed="loadCollaboration()" @unauthorized="loadCollaboration()">
<template #actions><UButton :to="{ path: '/communication/collaboration', query: { room: activeRoom.id } }" color="neutral" variant="outline" size="xs">Details und Freigaben</UButton></template>
</CollaborationRoom>
<main v-else class="flex min-h-0 flex-col">
<header class="border-b border-gray-200 px-5 py-4">
<h2 class="font-semibold text-gray-900">{{ activeRoom?.name || "Chat" }}</h2>
<p class="truncate text-sm text-gray-500">{{ activeRoom?.topic || "Wähle links einen Chatraum aus." }}</p>
@@ -264,29 +365,31 @@ onBeforeUnmount(() => {
<div ref="messagesViewport" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
<div v-if="roomLoading" class="py-10 text-center text-sm text-gray-500">Nachrichten werden geladen …</div>
<div v-else-if="!activeRoom" class="py-10 text-center text-sm text-gray-500">Dieser Chatraum ist nicht verfügbar. Wähle links einen anderen Raum.</div>
<div v-else-if="!messages.length" class="flex h-full flex-col items-center justify-center text-center text-gray-400">
<UIcon name="i-heroicons-chat-bubble-oval-left-ellipsis" class="mb-3 size-10" />
<p class="font-medium">Noch keine Nachrichten</p>
<p class="text-sm">Beginne die Unterhaltung.</p>
</div>
<div v-else class="space-y-4">
<ChatReactions v-else :key="`${session}:${activeRoomKey}`" v-slot="{ reactions, react, busy }" :api="$api" :endpoint="nativeEndpoint(activeRoomKey)" :messages="messages" @error="error => toast.add({ title: errorMessage(error), color: 'error' })">
<div class="space-y-4">
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
<div class="max-w-[80%] rounded-2xl px-4 py-2" :class="message.own ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-900'">
<div v-if="!message.own" class="mb-1 text-xs font-semibold text-primary-600">{{ message.senderDisplayName }}</div>
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
<ChatImage v-if="message.image" :api="$api" :endpoint="nativeEndpoint(activeRoomKey)" :message="message" />
<p v-if="message.body" class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
<div class="mt-1 text-right text-[11px]" :class="message.own ? 'text-primary-100' : 'text-gray-400'">{{ formatMessageTime(message.timestamp) }}</div>
<ChatReactionBar :reactions="reactions[message.id] || []" :busy="busy" @react="react(message.id, $event)" />
</div>
</div>
</div>
</ChatReactions>
</div>
<form class="flex items-end gap-3 border-t border-gray-200 p-4" @submit.prevent="sendMessage">
<UTextarea v-model="draft" class="flex-1" autoresize :rows="1" :maxrows="6" placeholder="Nachricht schreiben …" :disabled="!activeRoom?.exists" @keydown.enter.exact.prevent="sendMessage" />
<UButton type="submit" icon="i-heroicons-paper-airplane" :loading="sending" :disabled="!draft.trim() || !activeRoom?.exists">Senden</UButton>
</form>
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
</main>
<aside class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
<h2 class="mb-3 text-sm font-semibold text-gray-900">Teilnehmer</h2>
<div class="space-y-3">
<div v-for="member in members" :key="member.userId" class="flex items-center gap-3">

View File

@@ -2,7 +2,7 @@
const { $api } = useNuxtApp()
const route = useRoute()
const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' })
const contacts = ref([]), selected = ref(''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false)
const contacts = ref([]), selected = ref(typeof route.query.room === 'string' ? route.query.room : ''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false)
const management = ref({ members: [], invitations: [] }), inviteResult = ref(null)
const create = reactive({ name: '', topic: '', projectId: String(route.query.project || '') })
const invite = reactive({ kind: 'user', userId: '', name: '', email: '', contact: '', role: 'participant', overviewAllowed: false, expiresAt: '', sendEmail: true })
@@ -73,13 +73,14 @@ async function savePartner(origin, active = true) {
})
}
watch(selected, () => { showManagement.value = false; inviteResult.value = null; management.value = { members: [], invitations: [] } })
watch(() => route.query.room, id => { if (typeof id === 'string') selected.value = id })
let timer
onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) })
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div class="w-full space-y-5 p-4 sm:p-6">
<div class="min-h-0 min-w-0 w-full flex-1 space-y-5 overflow-y-auto p-4 sm:p-6">
<header class="flex flex-wrap items-start justify-between gap-3">
<div><h1 class="text-2xl font-semibold">Zusammenarbeit</h1><p class="mt-1 text-sm text-gray-500">Gemeinsame Chats und geschützte Projekt-Unterräume</p></div>
<div class="flex flex-wrap gap-2">
@@ -92,7 +93,7 @@ onBeforeUnmount(() => clearInterval(timer))
<section v-if="federation" class="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div class="flex justify-between"><h2 class="font-semibold">Freigegebene Partnerinstanzen</h2><button @click="federation = null">Schließen</button></div>
<p class="my-2 text-sm text-gray-500">Beide Firmen geben die jeweils andere Instanz frei. Einzelne Personen erhalten anschließend eine eigene Einladung.</p>
<p v-if="!federation.enabled" class="my-2 text-sm text-amber-700">Die Föderation muss zuerst vom Betreiber aktiviert werden.</p>
<p v-if="!federation.enabled" class="my-2 text-sm text-amber-700">{{ federation.disabledReason || 'Die Föderation muss zuerst vom Betreiber aktiviert werden.' }}</p>
<form v-if="federation.enabled" class="flex flex-wrap gap-2" @submit.prevent="savePartner(partnerUrl)"><label class="flex-1"><span class="sr-only">Adresse der Partnerinstanz</span><input v-model="partnerUrl" type="url" required placeholder="https://partner.example/backend" class="field" /></label><UButton type="submit" :loading="busy">Partner freigeben</UButton></form>
<div v-for="partner in federation.partners" :key="partner.origin" class="mt-3 flex items-center justify-between gap-3 text-sm"><span>{{ partner.origin }} · {{ partner.active ? 'Freigegeben' : 'Gesperrt' }}</span><UButton variant="outline" size="xs" :disabled="busy" @click="savePartner(partner.origin, !partner.active)">{{ partner.active ? 'Sperren' : 'Freigeben' }}</UButton></div>
</section>
@@ -120,7 +121,10 @@ onBeforeUnmount(() => clearInterval(timer))
</button>
</aside>
<div class="min-w-0 space-y-4">
<UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
<div class="flex flex-wrap gap-2">
<UButton v-if="readable" :to="{ path: '/communication/chat', query: { room: `collaboration:${selected}` } }" variant="outline">Im Chat öffnen</UButton>
<UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
</div>
<section v-if="showManagement && selectedManaged" class="space-y-5 rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<form class="space-y-3" @submit.prevent="invitePerson">
<h2 class="font-semibold">Person in diesen Unterraum einladen</h2>

View File

@@ -44,6 +44,7 @@ const itemInfo = ref({
special: null,
zip: null,
city: null,
country: null,
},
project: null,
costcentre: null,
@@ -791,6 +792,7 @@ const setCustomerData = async (customerId, loadOnlyAdress = false) => {
itemInfo.value.address.zip = customer.infoData.zip
itemInfo.value.address.city = customer.infoData.city
itemInfo.value.address.special = customer.infoData.special
itemInfo.value.address.country = customer.infoData.country
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
@@ -1542,6 +1544,8 @@ const getDocumentData = async () => {
console.log(returnTitleSumsTransfer)
const recipientCountry = String(itemInfo.value.address.country ?? customerData.infoData?.country ?? "").trim()
const returnData = {
type: itemInfo.value.type,
taxType: itemInfo.value.taxType,
@@ -1561,6 +1565,7 @@ const getDocumentData = async () => {
itemInfo.value.address.street,
...itemInfo.value.address.special ? [itemInfo.value.address.special] : [],
`${itemInfo.value.address.zip} ${itemInfo.value.address.city}`,
...(recipientCountry && recipientCountry.toLocaleLowerCase("de") !== "deutschland" ? [recipientCountry] : []),
],
/*info: {

View File

@@ -28,6 +28,7 @@
color="white"
variant="solid"
label="Ausführen"
:loading="isExecuting"
@click="openExecutionModal"
/>
@@ -70,21 +71,33 @@
<div>
<p class="text-sm font-medium">Start: {{ dayjs(exec.createdAt).format('DD.MM.YYYY HH:mm') }}</p>
</div>
<UBadge color="primary" variant="subtle">Läuft</UBadge>
<UBadge :color="getStatusColor(exec.status)" variant="subtle">{{ getStatusLabel(exec.status) }}</UBadge>
</div>
<div class="text-xs text-gray-500 flex justify-between mb-3">
<span>{{exec.summary}}</span>
</div>
<div class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
<div class="flex justify-end gap-2 pt-2 border-t border-gray-100 dark:border-gray-800">
<UButton
size="xs"
color="error"
variant="soft"
icon="i-heroicons-stop"
label="Durchlauf abbrechen"
:loading="cancellingId === exec.id"
:disabled="exec.status === 'cancelling' || cancellingId !== null"
@click="cancelExecution(exec.id)"
/>
<UButton
v-if="['draft', 'error'].includes(exec.status)"
size="xs"
color="white"
variant="solid"
icon="i-heroicons-check"
label="Fertigstellen"
:loading="finishingId === exec.id"
:disabled="finishingId !== null || cancellingId !== null"
@click="finishExecution(exec.id)"
/>
</div>
@@ -343,6 +356,14 @@ const showExecutionsSlideover = ref(false)
const executionItems = ref([])
const executionsLoading = ref(false)
const finishingId = ref(null)
const cancellingId = ref(null)
let executionsPoll
onMounted(() => {
executionsPoll = setInterval(() => {
if (!executionsLoading.value) fetchExecutions()
}, 3000)
})
onUnmounted(() => clearInterval(executionsPoll))
const setupPage = async () => {
items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber), plant(id,name)","documentDate",undefined,true)
@@ -364,11 +385,11 @@ const fetchExecutions = async () => {
}
const runningExecutions = computed(() => {
return executionItems.value.filter(i => i.status === 'draft')
return executionItems.value.filter(i => ['generating', 'draft', 'processing', 'cancelling', 'error'].includes(i.status))
})
const completedExecutions = computed(() => {
return executionItems.value.filter(i => i.status !== 'running' && i.status !== 'pending' && i.status !== 'draft')
return executionItems.value.filter(i => ['completed', 'cancelled'].includes(i.status))
})
const openExecutionsSlideover = () => {
@@ -376,16 +397,34 @@ const openExecutionsSlideover = () => {
fetchExecutions()
}
const cancelExecution = async (executionId) => {
if (cancellingId.value || !window.confirm('Durchlauf abbrechen? Ungebuchte Rechnungsentwürfe werden archiviert. Bereits gebuchte Rechnungen bleiben erhalten.')) return
cancellingId.value = executionId
try {
const result = await $api(`/api/functions/serial/cancel/${executionId}`, { method: 'POST' })
toast.add({
title: result.execution.status === 'cancelling' ? 'Abbruch angefordert' : 'Durchlauf abgebrochen',
description: result.execution.status === 'cancelling' ? 'Die gerade bearbeitete Rechnung wird noch abgeschlossen.' : 'Ungebuchte Rechnungsentwürfe wurden archiviert.',
color: 'success'
})
await fetchExecutions()
} catch (error) {
toast.add({ title: 'Abbruch fehlgeschlagen', description: error.data?.message || 'Der Durchlauf konnte nicht abgebrochen werden.', color: 'error' })
} finally {
cancellingId.value = null
}
}
const finishExecution = async (executionId) => {
if (!executionId) return
finishingId.value = executionId
try {
await $api(`/api/functions/serial/finish/${executionId}`, { method: 'POST' })
const result = await $api(`/api/functions/serial/finish/${executionId}`, { method: 'POST' })
toast.add({
title: 'Ausführung beendet',
description: 'Der Prozess wurde erfolgreich als fertig markiert.',
title: getStatusLabel(result.execution.status),
description: result.execution.summary,
icon: 'i-heroicons-check-circle',
color: 'green'
color: result.errors > 0 ? 'error' : 'success'
})
await fetchExecutions()
} catch (error) {
@@ -403,11 +442,13 @@ const finishExecution = async (executionId) => {
const getStatusColor = (status) => {
switch (status) {
case 'completed': return 'green'
case 'error': return 'red'
case 'completed': return 'success'
case 'error': return 'error'
case 'generating':
case 'processing':
case 'running':
case 'draft': return 'primary'
default: return 'gray'
default: return 'neutral'
}
}
@@ -415,7 +456,11 @@ const getStatusLabel = (status) => {
switch (status) {
case 'completed': return 'Abgeschlossen'
case 'error': return 'Fehlerhaft'
case 'draft': return 'Gestartet'
case 'draft': return 'Vorbereitet'
case 'generating': return 'Wird vorbereitet'
case 'processing': return 'Wird fertiggestellt'
case 'cancelling': return 'Wird abgebrochen'
case 'cancelled': return 'Abgebrochen'
default: return status
}
}
@@ -645,14 +690,15 @@ const executeSerialInvoices = async () => {
templateIds: selectedExecutionRows.value.map(row => row.id)
}
showExecutionModal.value = false
const res = await $api('/api/functions/serial/start', {
method: 'POST',
body: payload
})
toast.add({
title: 'Ausführung gestartet',
description: `${res.results.length} Rechnungen wurden für den Lauf vorbereitet.`,
title: res.execution.status === 'cancelled' ? 'Durchlauf abgebrochen' : 'Ausführung gestartet',
description: res.execution.status === 'cancelled' ? res.execution.summary : `${res.results.filter(result => result.status === 'success').length} Rechnungen wurden für den Lauf vorbereitet.`,
icon: 'i-heroicons-check-circle',
color: 'green'
})

View File

@@ -22,6 +22,69 @@ const linkedDocument =ref({})
const links = ref([])
const portalReleaseLoading = ref(false)
const bankBookingModalOpen = ref(false)
const costCentresOpen = ref(false)
const costCentresLoading = ref(false)
const costCentresSaving = ref(false)
const costCentres = ref([])
const costCentreDraft = ref({ costcentre: null, rows: [] })
const costCentreId = (value) => value?.id || value || null
const costCentreLabel = (id) => {
const centre = costCentres.value.find(item => item.id === id)
return centre ? [centre.number, centre.name].filter(Boolean).join(" - ") : (id ? "Bisherige Kostenstelle" : "Keine Kostenstelle")
}
const openCostCentres = async () => {
costCentresLoading.value = true
try {
costCentres.value = await useEntities("costcentres").select("*")
costCentreDraft.value = {
costcentre: costCentreId(itemInfo.value.costcentre),
rows: (itemInfo.value.rows || [])
.filter(row => row?.id != null && row.id !== "" && !["title", "subtitle", "text", "pagebreak"].includes(row.mode))
.map((row, index) => ({
rowId: String(row.id),
label: `${row.pos || index + 1}. ${row.text || row.name || row.descriptionText || "Position"}`,
costcentre: costCentreId(row.costCentre ?? row.costcentre),
})),
}
costCentresOpen.value = true
} catch (error) {
toast.add({ title: "Kostenstellen konnten nicht geladen werden", color: "error" })
} finally {
costCentresLoading.value = false
}
}
const saveCostCentres = async () => {
if (costCentresSaving.value) return
costCentresSaving.value = true
try {
const originalRows = new Map((itemInfo.value.rows || []).map(row => [String(row.id), row]))
const body = {
rowCostCentres: costCentreDraft.value.rows
.filter(row => row.costcentre !== costCentreId(originalRows.get(row.rowId)?.costCentre ?? originalRows.get(row.rowId)?.costcentre))
.map(({ rowId, costcentre }) => ({ rowId, costcentre })),
}
if (costCentreDraft.value.costcentre !== costCentreId(itemInfo.value.costcentre)) {
body.costcentre = costCentreDraft.value.costcentre
}
if (body.rowCostCentres.length || Object.hasOwn(body, "costcentre")) {
const { document } = await useNuxtApp().$api(`/api/functions/outgoing-documents/${itemInfo.value.id}/cost-centres`, {
method: "PUT",
body,
})
itemInfo.value.costcentre = document.costcentre
itemInfo.value.rows = document.rows
toast.add({ title: "Kostenstellen gespeichert", color: "success" })
}
costCentresOpen.value = false
} catch (error) {
toast.add({ title: "Kostenstellen konnten nicht gespeichert werden", description: error?.data?.error || "Bitte versuche es erneut.", color: "error" })
} finally {
costCentresSaving.value = false
}
}
const portalEligibleTypes = ["invoices", "advanceInvoices", "cancellationInvoices"]
const loadStatementAllocations = async () => {
@@ -212,6 +275,13 @@ const togglePortalRelease = async () => {
>
Bearbeiten
</UButton>
<UButton
v-if="itemInfo.state === 'Gebucht' && itemInfo.type !== 'serialInvoices'"
icon="i-heroicons-tag"
variant="outline"
:loading="costCentresLoading"
@click="openCostCentres"
>Kostenstellen bearbeiten</UButton>
<!-- <UButton
:to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''"
target="_blank"
@@ -286,6 +356,35 @@ const togglePortalRelease = async () => {
</template>
</UDashboardToolbar>
<UModal v-model:open="costCentresOpen" title="Kostenstellen bearbeiten" :dismissible="!costCentresSaving" :close="!costCentresSaving" :ui="{ content: 'sm:max-w-2xl' }">
<template #body>
<p class="mb-4 text-sm text-muted">Die Belegkostenstelle und abweichende Positionskostenstellen können nachträglich geändert oder entfernt werden.</p>
<div class="space-y-4">
<UFormField label="Belegkostenstelle">
<InputGroup>
<USelectMenu v-model="costCentreDraft.costcentre" :items="costCentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
<template #default>{{ costCentreLabel(costCentreDraft.costcentre) }}</template>
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
</USelectMenu>
<UButton icon="i-heroicons-x-mark" variant="outline" aria-label="Belegkostenstelle entfernen" :disabled="costCentresSaving || !costCentreDraft.costcentre" @click="costCentreDraft.costcentre = null" />
</InputGroup>
</UFormField>
<UFormField v-for="row in costCentreDraft.rows" :key="row.rowId" :label="row.label">
<InputGroup>
<USelectMenu v-model="row.costcentre" :items="costCentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
<template #default>{{ costCentreLabel(row.costcentre) }}</template>
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
</USelectMenu>
<UButton icon="i-heroicons-x-mark" variant="outline" :aria-label="`Kostenstelle für ${row.label} entfernen`" :disabled="costCentresSaving || !row.costcentre" @click="row.costcentre = null" />
</InputGroup>
</UFormField>
</div>
</template>
<template #footer>
<UButton variant="outline" :disabled="costCentresSaving" @click="costCentresOpen = false">Abbrechen</UButton>
<UButton :loading="costCentresSaving" @click="saveCostCentres">Kostenstellen speichern</UButton>
</template>
</UModal>
<UModal v-model:open="bankBookingModalOpen" :ui="{ content: 'sm:max-w-2xl' }">
<template #content>
<UCard>

View File

@@ -36,6 +36,12 @@ const previewDocument = ref<InboxDocument | null>(null)
const filingDocument = ref<InboxDocument | null>(null)
const filingLoading = ref(false)
const filingSaving = ref(false)
const archiveOpen = ref(false)
const archiveTarget = ref<InboxDocument | null>(null)
const archiveSaving = ref(false)
const archiveError = ref("")
let archiveVersion = 0
const documentBusy = computed(() => filingSaving.value || archiveSaving.value)
const filingError = ref("")
const filingFolders = ref<FilingFolder[]>([])
const filingTypes = ref<FilingType[]>([])
@@ -80,7 +86,7 @@ const folderOptions = computed(() => {
return [{ id: "root", name: "Dateien (Hauptebene)" }, ...filingFolders.value
.map(folder => ({ id: folder.id, name: path(folder) })).sort((a, b) => a.name.localeCompare(b.name, "de"))]
})
const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !filingSaving.value
const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !documentBusy.value
&& (filingFolder.value === "root" || selectedFolder.value)
&& filingTypes.value.some(type => type.id === filingType.value)
&& (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype)))
@@ -225,6 +231,7 @@ const removeAssignment = (kind: string) => {
const openPreview = (entry: InboxDocument) => openFiling(entry)
const openFiling = async (entry: InboxDocument) => {
if (disposed || documentBusy.value) return
suggestionVersion++
clearTimeout(suggestionTimer)
suggestionController?.abort()
@@ -278,6 +285,44 @@ const openFiling = async (entry: InboxDocument) => {
}
}
const removeInboxEntry = (id: string) => {
// Discard older list responses that still contain the completed document.
requestVersion++
clearTimeout(pollTimer)
if (documents.value.some(document => document.id === id)) total.value = Math.max(0, total.value - 1)
documents.value = documents.value.filter(document => document.id !== id)
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
void inboxCount.refresh()
if (previewDocument.value?.id === id) previewOpen.value = false
}
const openArchive = (entry: InboxDocument) => {
if (disposed || documentBusy.value || !auth.activeTenant) return
archiveTarget.value = entry
archiveError.value = ""
archiveOpen.value = true
}
const archiveDocument = async () => {
const entry = archiveTarget.value
if (!entry || !archiveOpen.value || disposed || documentBusy.value || !auth.activeTenant) return
const version = ++archiveVersion
archiveSaving.value = true
archiveError.value = ""
try {
await $api(`/api/document-inbox/${entry.id}/archive`, { method: "POST" })
if (disposed || version !== archiveVersion) return
removeInboxEntry(entry.id)
archiveOpen.value = false
toast.add({ title: "Dokument archiviert", description: `${entry.name} wurde aus der Ablage entfernt.`, color: "success" })
await load(true)
} catch (cause: any) {
if (!disposed && version === archiveVersion) archiveError.value = cause?.data?.error || cause?.data?.message || "Das Dokument konnte nicht archiviert werden."
} finally {
if (version === archiveVersion) archiveSaving.value = false
}
}
const fileDocument = async () => {
if (!canFile.value) return
const entry = filingDocument.value!
@@ -288,14 +333,7 @@ const fileDocument = async () => {
try {
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value, assignments: { ...assignmentChanges.value } } })
if (disposed || version !== filingVersion) return
// Cancel pending polling responses before removing the successfully filed entry.
requestVersion++
clearTimeout(pollTimer)
documents.value = documents.value.filter(document => document.id !== entry.id)
total.value = Math.max(0, total.value - 1)
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
void inboxCount.refresh()
previewOpen.value = false
removeInboxEntry(entry.id)
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
await load(true)
} catch (cause: any) {
@@ -429,7 +467,15 @@ watch(previewOpen, open => {
filingLoading.value = false
}
})
watch(archiveOpen, open => {
if (!open) { archiveTarget.value = null; archiveError.value = "" }
})
watch(() => auth.activeTenant, () => {
archiveVersion++
archiveOpen.value = false
archiveTarget.value = null
archiveError.value = ""
archiveSaving.value = false
requestVersion++
setupVersion++
filingVersion++
@@ -455,6 +501,7 @@ watch(() => auth.activeTenant, () => {
onMounted(() => { origin.value = window.location.origin; load() })
onBeforeUnmount(() => {
disposed = true
archiveVersion++
requestVersion++
setupVersion++
filingVersion++
@@ -507,7 +554,12 @@ onBeforeUnmount(() => {
</td>
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ dayjs(entry.receivedAt).format('DD.MM.YYYY HH:mm') }}</td>
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ formatSize(entry.size) }}</td>
<td class="px-4 py-4"><div class="flex justify-end gap-1"><UButton icon="i-heroicons-folder-arrow-down" variant="soft" :aria-label="`${entry.name} ablegen`" @click="openFiling(entry)">Ablegen</UButton><UButton icon="i-heroicons-eye" color="neutral" variant="ghost" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)">Vorschau</UButton><UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="ghost" :aria-label="`${entry.name} herunterladen`" @click="fileAction(entry, true)" /></div></td>
<td class="px-4 py-4"><div class="flex justify-end gap-1">
<UButton icon="i-heroicons-folder-arrow-down" variant="soft" :aria-label="`${entry.name} ablegen`" :disabled="documentBusy" @click="openFiling(entry)">Ablegen</UButton>
<UButton icon="i-heroicons-eye" color="neutral" variant="ghost" :aria-label="`${entry.name} als Vorschau anzeigen`" :disabled="documentBusy" @click="openPreview(entry)">Vorschau</UButton>
<UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="ghost" :aria-label="`${entry.name} herunterladen`" @click="fileAction(entry, true)" />
<UButton icon="i-heroicons-archive-box" color="neutral" variant="ghost" :aria-label="`${entry.name} archivieren`" :disabled="documentBusy" @click="openArchive(entry)">Archivieren</UButton>
</div></td>
</tr>
</tbody>
</table>
@@ -522,7 +574,7 @@ onBeforeUnmount(() => {
</div>
</UDashboardPanelContent>
<UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Dokument prüfen und mit Ordner und Typ ablegen." :dismissible="!filingSaving" :close="!filingSaving" :ui="{ content: 'w-[calc(100vw-2rem)] max-w-7xl h-[90dvh]', body: 'min-h-0 flex-1 overflow-y-auto lg:overflow-hidden p-0 sm:p-0', title: 'break-all pr-8', footer: 'flex-wrap justify-end' }">
<UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Dokument prüfen und mit Ordner und Typ ablegen." :dismissible="!documentBusy" :close="!documentBusy" :ui="{ content: 'w-[calc(100vw-2rem)] max-w-7xl h-[90dvh]', body: 'min-h-0 flex-1 overflow-y-auto lg:overflow-hidden p-0 sm:p-0', title: 'break-all pr-8', footer: 'flex-wrap justify-end' }">
<template #body>
<div class="lg:flex lg:h-full lg:min-h-0">
<div class="h-[50dvh] min-w-0 lg:h-full lg:flex-1">
@@ -597,6 +649,7 @@ onBeforeUnmount(() => {
</template>
<template #footer>
<template v-if="previewDocument">
<UButton icon="i-heroicons-archive-box" color="neutral" variant="ghost" :disabled="documentBusy" @click="openArchive(previewDocument)">Archivieren</UButton>
<UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" @click="fileAction(previewDocument, false)">In neuem Tab öffnen</UButton>
<UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="outline" @click="fileAction(previewDocument, true)">Herunterladen</UButton>
<UButton type="submit" form="inbox-preview-filing" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">Übernehmen und ablegen</UButton>
@@ -604,6 +657,17 @@ onBeforeUnmount(() => {
</template>
</UModal>
<UModal v-model:open="archiveOpen" title="Dokument archivieren" description="Das Dokument verschwindet aus der Ablage. Die Originaldatei bleibt im Archiv erhalten." :dismissible="!archiveSaving" :close="!archiveSaving">
<template #body>
<p class="break-all font-medium">{{ archiveTarget?.name }}</p>
<UAlert v-if="archiveError" class="mt-4" :title="archiveError" color="error" variant="subtle" />
</template>
<template #footer>
<UButton color="neutral" variant="outline" :disabled="archiveSaving" @click="archiveOpen = false">Abbrechen</UButton>
<UButton icon="i-heroicons-archive-box" :loading="archiveSaving" :disabled="!archiveTarget || filingSaving" @click="archiveDocument">Archivieren</UButton>
</template>
</UModal>
<UModal v-model:open="setupOpen" title="Scanner einrichten" description="Verbinde deinen Epson-Scanner mit der Dokumentenablage dieses Mandanten.">
<template #body>
<div class="space-y-5">

View File

@@ -6,6 +6,8 @@ const router = useRouter()
const items = [{
label: 'Profil',
},{
label: 'Benachrichtigungen',
},{
label: 'Projekte',
content: 'This is the content shown for Tab1'
@@ -57,6 +59,7 @@ const isLight = computed({
</InputGroup>
</div>
<NotificationPreferences v-else-if="item.label === 'Benachrichtigungen'" />
<div v-else-if="item.label === 'Projekte'">
<USeparator
label="Phasenvorlagen"

View File

@@ -79,6 +79,11 @@ async function loadUsers() {
async function load() {
if (!selectedUser.value) return
const context = await useNuxtApp().$api('/api/v2/time/context')
if (context.profiles.some(p => p.user_id === selectedUser.value && p.activated_at)) {
await router.replace({ path: '/v2/time', query: { user_id: selectedUser.value } })
return
}
entries.value = await list({ user_id: selectedUser.value })
}

238
frontend/pages/v2/time.vue Normal file
View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { timeSpanInput, localTimeInput } from '~/utils/timeSpanInput';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
definePageMeta({ layout: 'default' });
const { $api } = useNuxtApp();
const auth = useAuthStore();
const route = useRoute();
const selected = ref(String(route.query.user_id || auth.user?.id || ''));
const context = ref<any>({ profiles: [], projects: [], permissions: {} });
const today = dayjs().tz('Europe/Berlin');
const from = ref(today.startOf('month').format('YYYY-MM-DD')), to = ref(today.endOf('month').format('YYYY-MM-DD'));
const tab = ref('mine'), view = ref('list'), statusFilter = ref('all');
const entries = ref<any[]>([]), active = ref<any>(null), account = ref<any>(null), evaluation = ref<any>(null);
const busy = ref(false), loading = ref(false), error = ref(''), notice = ref(''), preview = ref<any>(null);
const editorElement = ref<HTMLElement | null>(null);
const edit = ref<any>(null), history = ref<any[] | null>(null), historyEntry = ref(''), rejection = ref<any>(null), reason = ref('');
const now = ref(Date.now());
let timer: ReturnType<typeof setInterval> | undefined;
let loadSequence = 0;
const labels: Record<string, string> = { work: 'Anwesenheit', vacation: 'Urlaub', sick: 'Krankheit', overtime_compensation: 'Freizeitausgleich', holiday: 'Feiertag', other: 'Sonstiges' };
const states: Record<string, string> = { factual: 'Erfasst', submitted: 'Eingereicht', approved: 'Genehmigt', rejected: 'Abgelehnt' };
const canWrite = computed(() => selected.value === auth.user?.id || context.value.permissions.writeAll);
const paused = computed(() => active.value?.breaks?.some((p: any) => !p.ended_at));
const shown = computed(() => entries.value.filter(e => statusFilter.value === 'all' || e.status === statusFilter.value));
const groups = computed(() => {
const map = new Map<string, any[]>();
for (const entry of shown.value) {
const key = local(entry.started_at).slice(0, 10);
map.set(key, [...(map.get(key) || []), entry]);
}
return [...map.entries()];
});
const local = (value: string | null, zone = 'Europe/Berlin') => value ? localTimeInput(value, zone) : '';
const formatted = (value: string | null) => value ? new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'shortOffset' }).format(new Date(value)) : 'läuft';
const hours = (minutes: number) => { const m = Math.round(Math.abs(minutes || 0)); return `${minutes < 0 ? '−' : ''}${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`; };
function duration(e: any) {
const end = e.ended_at ? Date.parse(e.ended_at) : now.value;
return (end - Date.parse(e.started_at) - (e.breaks || []).reduce((n: number, p: any) => n + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0)) / 60000;
}
function message(e: any) { return e?.data?.error || e?.message || 'Die Aktion konnte nicht ausgeführt werden.'; }
async function loadContext() { context.value = await $api('/api/v2/time/context'); }
async function load() {
if (!selected.value)
return;
const sequence = ++loadSequence;
loading.value = true;
error.value = '';
try {
const q = new URLSearchParams({ user_id: selected.value, from: from.value, to: to.value });
const [result, report] = await Promise.all([$api(`/api/v2/time/spans?${q}`), $api(`/api/v2/time/evaluation?${q}`)]) as any[];
if (sequence !== loadSequence)
return;
entries.value = result.items;
active.value = result.active;
account.value = result.account;
evaluation.value = report.summary;
}
catch (e) {
if (sequence === loadSequence) {
error.value = message(e);
entries.value = [];
active.value = null;
account.value = null;
evaluation.value = null;
}
}
finally {
if (sequence === loadSequence)
loading.value = false;
}
}
async function perform(fn: () => Promise<any>) {
if (busy.value)
return;
busy.value = true;
error.value = '';
notice.value = '';
try {
await fn();
}
catch (e) {
error.value = message(e);
}
finally {
busy.value = false;
}
}
async function command(cmd: any) {
await $api('/api/v2/time/commands', { method: 'POST', body: { user_id: selected.value, request_id: crypto.randomUUID(), command: cmd } });
await load();
notice.value = 'Zeit gespeichert.';
}
function live(action: string) { return perform(() => command({ action, at: new Date().toISOString(), ...(action !== 'start' ? { id: active.value.id, version: active.value.version } : {}) })); }
function workflow(e: any, action: string) { return perform(() => command({ action, id: e.id, version: e.version })); }
function openEdit(e?: any) {
history.value = null;
const zone = e?.timezone || 'Europe/Berlin';
edit.value = { id: e?.id, version: e?.version, type: e?.type || 'work', description: e?.description || '', timezone: zone,
started_at: local(e?.started_at || new Date().toISOString(), zone), ended_at: local(e?.ended_at, zone), original_start: e?.started_at, original_end: e?.ended_at,
allDay: Boolean(e?.date_from), date_from: e?.date_from || today.format('YYYY-MM-DD'), date_to: e?.date_to ? dayjs.utc(e.date_to).subtract(1, 'day').format('YYYY-MM-DD') : today.format('YYYY-MM-DD'),
breaks: (e?.breaks || []).map((p: any) => ({ ...p, started_at: local(p.started_at, zone), ended_at: local(p.ended_at, zone), original_start: p.started_at, original_end: p.ended_at })),
projects: (e?.projects || []).map((p: any) => ({ ...p, started_at: local(p.started_at, zone), ended_at: local(p.ended_at, zone), original_start: p.started_at, original_end: p.ended_at })), reason: '' };
nextTick(() => editorElement.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
}
function save() {
return perform(async () => {
const e = edit.value, zone = e.timezone;
const period = (p: any) => ({ started_at: timeSpanInput(p.started_at, zone, p.original_start), ended_at: p.ended_at ? timeSpanInput(p.ended_at, zone, p.original_end) : null });
const data = { type: e.type, description: e.description, timezone: zone,
...(e.allDay && e.type !== 'work' ? { date_from: e.date_from, date_to: dayjs.utc(e.date_to).add(1, 'day').format('YYYY-MM-DD') } : period(e)),
breaks: e.type === 'work' ? e.breaks.map(period) : [], projects: e.type === 'work' ? e.projects.map((p: any) => ({ ...period(p), project_id: Number(p.project_id), notes: p.notes })) : [] };
await command({ action: e.id ? 'update' : 'create', id: e.id, version: e.version, data, reason: e.reason });
edit.value = null;
});
}
function showHistory(e: any) { return perform(async () => { history.value = await $api(`/api/v2/time/spans/${e.id}/history`) as any[]; historyEntry.value = formatted(e.started_at); }); }
function migrationPreview() { return perform(async () => { preview.value = await $api(`/api/v2/time/migration?user_id=${selected.value}`); }); }
function activate() {
return perform(async () => {
await $api('/api/v2/time/activate', { method: 'POST', body: { user_id: selected.value, fingerprint: preview.value.fingerprint } });
preview.value = null;
await loadContext();
await load();
notice.value = 'Zeiterfassung 2.0 aktiviert. Alle Erfassungswege verwenden jetzt die gespeicherten Spannen.';
});
}
function segments(e: any) {
const end = e.ended_at ? Date.parse(e.ended_at) : now.value, start = Date.parse(e.started_at), length = Math.max(1, end - start);
return (e.breaks || []).map((p: any) => ({ left: `${100 * (Date.parse(p.started_at) - start) / length}%`, width: `${100 * ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)) / length}%` }));
}
watch(selected, () => { edit.value = null; preview.value = null; history.value = null; rejection.value = null; load(); });
watch(tab, value => { if (value === 'mine')
selected.value = auth.user?.id || selected.value; statusFilter.value = value === 'team' ? 'submitted' : 'all'; });
onMounted(async () => { timer = setInterval(() => { now.value = Date.now(); }, 1000); await perform(async () => { await loadContext(); await load(); }); });
onUnmounted(() => { if (timer)
clearInterval(timer); });
</script>
<template>
<UDashboardPanel id="time-v2">
<template #header><UDashboardNavbar title="Zeiterfassung 2.0" /></template>
<template #body>
<div class="mx-auto w-full max-w-6xl space-y-6 pb-12">
<div class="flex flex-wrap gap-2" role="tablist" aria-label="Zeitansichten">
<UButton :variant="tab === 'mine' ? 'solid' : 'soft'" @click="tab='mine'">Meine Zeiten</UButton>
<UButton v-if="context.permissions.readAll" :variant="tab === 'team' ? 'solid' : 'soft'" @click="tab='team'">Team &amp; Freigaben</UButton>
<UButton :variant="tab === 'evaluation' ? 'solid' : 'soft'" @click="tab='evaluation'">Auswertung</UButton>
</div>
<p v-if="error" role="alert" class="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:text-red-200">{{ error }}</p>
<p v-if="notice" role="status" class="rounded-lg bg-green-50 p-4 text-green-800 dark:bg-green-950 dark:text-green-200">{{ notice }}</p>
<form class="flex flex-wrap items-end gap-4" @submit.prevent="load">
<label v-if="context.permissions.readAll" class="field">Mitarbeiter<select v-model="selected" :disabled="busy"><option v-for="p in context.profiles" :key="p.user_id" :value="p.user_id">{{ p.full_name }}</option></select></label>
<label class="field">Von<input v-model="from" type="date" required></label>
<label class="field">Bis<input v-model="to" type="date" required></label>
<UButton type="submit" :loading="loading" :disabled="busy">Anzeigen</UButton>
</form>
<section v-if="!account && !loading" class="rounded-xl border border-amber-300 p-5 space-y-3">
<h2 class="font-semibold">Zeiterfassung 2.0 aktivieren</h2>
<p>Vor der Umstellung werden bestehende Zeiten und Projektzuordnungen geprüft. Nach der Aktivierung nutzen auch die Mobile-App und die bisherigen Zeitanzeigen dieses Modell.</p>
<UButton v-if="context.permissions.activate" :loading="busy" @click="migrationPreview">Übernahme prüfen</UButton>
<p v-else>Die Aktivierung erfolgt durch einen Administrator oder eine Person mit Aktivierungsberechtigung.</p>
<NuxtLink to="/staff/time" class="underline block">Bisherige Zeiterfassung öffnen</NuxtLink>
<div v-if="preview" class="space-y-3">
<p>{{ preview.count }} Einträge · {{ hours(preview.totalMinutes) }} Arbeitszeit zur Übernahme</p>
<ul v-if="preview.issues.length" class="list-disc pl-5"><li v-for="issue in preview.issues" :key="issue">{{ issue }}</li></ul>
<details v-if="preview.candidates.length"><summary class="cursor-pointer">Einträge prüfen</summary><ul class="max-h-64 overflow-auto text-sm"><li v-for="(c,i) in preview.candidates" :key="i">{{ labels[c.span.type] }} · {{ formatted(c.span.started_at) }} – {{ formatted(c.span.ended_at) }} · {{ states[c.span.status] }}</li></ul></details>
<UButton :disabled="preview.issues.length > 0 || busy" @click="activate">Geprüfte Daten übernehmen und aktivieren</UButton>
</div>
</section>
<template v-if="account">
<section v-if="tab !== 'evaluation'" class="rounded-xl border border-default p-5 space-y-4">
<div class="flex flex-wrap justify-between gap-4">
<div><p class="text-sm text-muted">Aktueller Status</p><h2 class="text-xl font-semibold">{{ active ? (active.type === 'work' ? paused ? 'In Pause' : 'Anwesend' : labels[active.type]) : 'Nicht anwesend' }}</h2>
<p v-if="active" class="text-sm">Seit {{ formatted(active.started_at) }} · {{ hours(duration(active)) }} {{ active.type === 'work' ? 'Arbeitszeit' : '' }}</p></div>
<div v-if="canWrite" class="flex flex-wrap items-center gap-2">
<UButton v-if="!active" :disabled="busy || loading" @click="live('start')">Kommen</UButton>
<template v-else-if="active.type === 'work'"><UButton :disabled="busy || loading" variant="soft" @click="live(paused ? 'resume' : 'pause')">{{ paused ? 'Pause beenden' : 'Pause beginnen' }}</UButton><UButton :disabled="busy || loading" @click="live('stop')">Gehen</UButton></template>
<UButton v-else :disabled="busy" @click="openEdit(active)">Eintrag abschließen</UButton>
<UButton variant="outline" :disabled="busy" @click="openEdit()">Manuell erfassen</UButton>
</div>
</div>
</section>
<section v-if="tab === 'evaluation' && evaluation" class="space-y-4">
<p class="text-sm text-muted">Abgeschlossene Zeiten · Zeitzone Europe/Berlin · Freizeitausgleich und Sonstiges erzeugen keine Zeitgutschrift.</p>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div v-for="[label,key] in [['Sollzeit','timeSpanWorkingMinutes'],['Anwesenheit','presenceMinutes'],['Pausen','breakMinutes'],['Eingereicht inkl. genehmigt','sumWorkingMinutesSubmitted'],['Genehmigte Arbeitszeit','sumWorkingMinutesApproved'],['Urlaub','sumWorkingMinutesVacationDays'],['Krankheit','sumWorkingMinutesSickDays'],['Feiertagsausgleich','sumWorkingMinutesRecreationDays'],['Freizeitausgleich','sumOvertimeCompensationMinutes'],['Sonstiges','sumOtherMinutes'],['Saldo eingereicht','saldoSubmitted'],['Saldo genehmigt','saldoApproved']]" :key="key" class="rounded-xl border border-default p-4"><p class="text-sm text-muted">{{ label }}</p><p class="text-xl font-semibold tabular-nums">{{ hours(evaluation[key]) }}</p></div>
</div>
<p>Urlaub: {{ Number(evaluation.sumVacationDays).toLocaleString('de-DE') }} Arbeitstage · Krankheit: {{ Number(evaluation.sumSickDays).toLocaleString('de-DE') }} Arbeitstage · Feiertage: {{ evaluation.sumRecreationDays }}</p>
</section>
<template v-else>
<div class="flex flex-wrap items-center justify-between gap-3"><label class="field">Status<select v-model="statusFilter"><option value="all">Alle</option><option v-for="(label,key) in states" :key="key" :value="key">{{ label }}</option></select></label><div class="flex gap-2"><UButton :variant="view === 'list' ? 'solid' : 'soft'" @click="view='list'">Liste</UButton><UButton :variant="view === 'timeline' ? 'solid' : 'soft'" @click="view='timeline'">Zeitstrahl</UButton></div></div>
<p v-if="!shown.length && !loading" class="py-8 text-center text-muted">Keine Einträge für diese Auswahl.</p>
<section v-for="[date,items] in groups" :key="date" class="space-y-3">
<h2 class="font-semibold">{{ dayjs(date).format('DD.MM.YYYY') }}</h2>
<article v-for="e in items" :key="e.id" class="rounded-xl border border-default p-4 space-y-3">
<div class="flex flex-wrap justify-between gap-3"><div><strong>{{ labels[e.type] }}</strong><span class="ml-3 text-sm">{{ states[e.status] }}</span><p class="text-sm">{{ formatted(e.started_at) }} – {{ formatted(e.ended_at) }}</p><p v-if="e.description" class="whitespace-pre-wrap text-sm text-muted">{{ e.description }}</p></div><strong class="tabular-nums">{{ hours(duration(e)) }}</strong></div>
<div v-if="view === 'timeline'" class="space-y-1"><div class="relative h-7 overflow-hidden rounded bg-primary/70" role="img" :aria-label="`${labels[e.type]} mit ${e.breaks.length} Pausen`"><span v-for="(style,i) in segments(e)" :key="i" :style="style" class="absolute top-0 h-full bg-amber-400" /></div><p class="text-xs text-muted">Grün: Anwesenheit · Gelb: Pause · Skala je Eintrag</p></div>
<p v-for="(p,i) in e.breaks" :key="i" class="text-sm">Pause: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }}</p>
<p v-for="p in e.projects" :key="p.id" class="text-sm">Projekt {{ context.projects.find((x:any) => Number(x.id) === Number(p.project_id))?.name || p.project_id }}: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }} · {{ p.notes }}</p>
<div class="flex flex-wrap gap-2"><UButton v-if="canWrite" size="sm" variant="soft" :disabled="busy" @click="openEdit(e)">Bearbeiten</UButton><UButton v-if="canWrite && e.ended_at && ['factual','rejected'].includes(e.status)" size="sm" :disabled="busy" @click="workflow(e,'submit')">Einreichen</UButton><template v-if="context.permissions.approve && e.status === 'submitted'"><UButton size="sm" :disabled="busy" @click="workflow(e,'approve')">Genehmigen</UButton><UButton size="sm" color="error" variant="soft" :disabled="busy" @click="rejection=e; reason=''">Ablehnen</UButton></template><UButton size="sm" variant="ghost" :disabled="busy" @click="showHistory(e)">Verlauf</UButton></div>
</article>
</section>
</template>
</template>
<section v-if="rejection" class="rounded-xl border border-default p-5 space-y-3"><h2 class="font-semibold">Eintrag ablehnen</h2><label class="field">Begründung<textarea v-model="reason" /></label><div class="flex gap-2"><UButton :disabled="!reason.trim() || busy" @click="perform(async () => { await command({action:'reject',id:rejection.id,version:rejection.version,reason}); rejection=null })">Ablehnung speichern</UButton><UButton variant="ghost" @click="rejection=null">Abbrechen</UButton></div></section>
<section v-if="edit" ref="editorElement" class="rounded-xl border-2 border-primary p-5 space-y-4">
<h2 class="text-lg font-semibold">{{ edit.id ? 'Eintrag bearbeiten' : 'Zeit erfassen' }}</h2>
<p v-if="edit.id" class="text-sm">Änderungen setzen eine bestehende Einreichung oder Genehmigung zurück.</p>
<form class="space-y-4" @submit.prevent="save">
<div class="grid gap-4 md:grid-cols-2"><label class="field">Art<select v-model="edit.type"><option v-for="(label,key) in labels" :key="key" :value="key">{{ label }}</option></select></label><label class="field">Zeitzone<input v-model="edit.timezone" required placeholder="Europe/Berlin"></label></div>
<label v-if="edit.type !== 'work'" class="flex items-center gap-2"><input v-model="edit.allDay" type="checkbox">Ganztägig</label>
<div v-if="edit.allDay && edit.type !== 'work'" class="grid gap-4 md:grid-cols-2"><label class="field">Erster Tag<input v-model="edit.date_from" type="date" required></label><label class="field">Letzter Tag (einschließlich)<input v-model="edit.date_to" type="date" :min="edit.date_from" required></label></div>
<div v-else class="grid gap-4 md:grid-cols-2"><label class="field">Beginn<input v-model="edit.started_at" type="datetime-local" required></label><label class="field">Ende (leer = läuft)<input v-model="edit.ended_at" type="datetime-local"></label></div>
<label class="field">Beschreibung<textarea v-model="edit.description" maxlength="10000" /></label>
<template v-if="edit.type === 'work'">
<fieldset class="space-y-3"><legend class="font-semibold">Pausen</legend><div v-for="(p,i) in edit.breaks" :key="i" class="flex flex-wrap items-end gap-3"><label class="field">Beginn<input v-model="p.started_at" type="datetime-local" required></label><label class="field">Ende<input v-model="p.ended_at" type="datetime-local"></label><UButton variant="ghost" color="error" @click="edit.breaks.splice(i,1)">Entfernen</UButton></div><UButton variant="soft" @click="edit.breaks.push({started_at:edit.started_at,ended_at:edit.ended_at})">Pause hinzufügen</UButton></fieldset>
<fieldset class="space-y-3"><legend class="font-semibold">Projektzuordnungen</legend><div v-for="(p,i) in edit.projects" :key="i" class="grid gap-3 rounded-lg border border-default p-3 md:grid-cols-2"><label class="field">Projekt<select v-model="p.project_id" required><option value="">Bitte wählen</option><option v-for="project in context.projects" :key="project.id" :value="project.id">{{ project.name }}</option></select></label><label class="field">Notiz<input v-model="p.notes"></label><label class="field">Beginn<input v-model="p.started_at" type="datetime-local" required></label><label class="field">Ende<input v-model="p.ended_at" type="datetime-local" required></label><UButton variant="ghost" color="error" @click="edit.projects.splice(i,1)">Zuordnung entfernen</UButton></div><UButton variant="soft" @click="edit.projects.push({project_id:'',started_at:edit.started_at,ended_at:edit.ended_at,notes:''})">Projekt hinzufügen</UButton></fieldset>
</template>
<label v-if="edit.id" class="field">Korrekturgrund<input v-model="edit.reason" required></label>
<div class="flex gap-2"><UButton type="submit" :loading="busy">Speichern</UButton><UButton variant="ghost" :disabled="busy" @click="edit=null">Abbrechen</UButton></div>
</form>
</section>
<section v-if="history" class="rounded-xl border border-default p-5 space-y-3"><div class="flex justify-between"><h2 class="font-semibold">Verlauf · {{ historyEntry }}</h2><UButton variant="ghost" @click="history=null">Schließen</UButton></div><details v-for="h in history" :key="h.id" class="border-b border-default py-2"><summary class="cursor-pointer">{{ formatted(h.created_at) }} · {{ ({create:'Erstellt',start:'Gekommen',stop:'Gegangen',pause:'Pause begonnen',resume:'Pause beendet',update:'Korrigiert',submit:'Eingereicht',approve:'Genehmigt',reject:'Abgelehnt',migration:'Übernommen'} as any)[h.action] || h.action }} · {{ context.profiles.find((p:any) => p.user_id === h.actor_id)?.full_name || h.actor_id }}</summary><p class="py-2">{{ h.reason }}</p><div class="grid gap-4 md:grid-cols-2"><div v-for="[label,snapshot] in [['Vorher',h.before_data],['Nachher',h.after_data]]" :key="label" class="text-sm"><strong>{{ label }}</strong><template v-if="snapshot"><p>{{ labels[snapshot.type] }} · {{ states[snapshot.status] }}</p><p>{{ formatted(snapshot.started_at) }} – {{ formatted(snapshot.ended_at) }}</p><p>{{ snapshot.description }}</p><p v-for="(p,i) in snapshot.breaks" :key="i">Pause: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }}</p><p v-for="(p,i) in snapshot.projects" :key="i">Projekt {{ p.project_id }}: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }} · {{ p.notes }}</p></template><p v-else>—</p></div></div></details></section>
</div>
</template>
</UDashboardPanel>
</template>
<style scoped>
.field { display: flex; flex-direction: column; gap: .35rem; font-size: .875rem; }
.field input, .field select, .field textarea { border: 1px solid var(--ui-border, #cbd5e1); border-radius: .5rem; padding: .5rem .75rem; background: var(--ui-bg, white); color: inherit; min-width: 0; }
.field textarea { min-height: 5rem; }
.field input:focus, .field select:focus, .field textarea:focus { outline: 2px solid var(--ui-primary, #69c350); outline-offset: 2px; }
</style>

View File

@@ -12,6 +12,7 @@ import plant from "~/components/columnRenderings/plant.vue"
import vendor from "~/components/columnRenderings/vendor.vue"
import product from "~/components/columnRenderings/product.vue"
import active from "~/components/columnRenderings/active.vue"
import projectChatEnabled from "~/components/columnRenderings/projectChatEnabled.vue"
import sellingPrice from "~/components/columnRenderings/sellingPrice.vue";
import unit from "~/components/columnRenderings/unit.vue";
import isCompany from "~/components/columnRenderings/isCompany.vue"
@@ -1717,6 +1718,16 @@ export const useDataStore = defineStore('data', () => {
key: "customerRef",
label: "Referenz des Kunden",
inputType: "text"
},{
key: "chatEnabled",
label: "Projektchat aktiviert",
inputType: "bool",
component: projectChatEnabled,
defaultValue: true,
disabledFunction: () => {
const auth = useAuthStore()
return !auth.user?.is_admin && !auth.hasPermission('organisation.projects.write')
}
},{
key: "notes",
label: "Notizen",

View File

@@ -0,0 +1,379 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
const settle = () => new Promise(setImmediate)
const deferred = () => {
let resolve
const promise = new Promise(done => { resolve = done })
return { promise, resolve }
}
const source = fs.readFileSync(`${__dirname}/../pages/communication/chat.vue`, 'utf8').match(/<script setup>([\s\S]*?)<\/script>/)[1]
const nativeRoom = { key: 'allgemein', name: 'Allgemein', exists: true, type: 'general' }
const sharedRoom = { id: 'shared-room', name: 'Partnerchat', remote: true, unread: 3 }
function fixture(override = () => undefined) {
return async (url, options) => {
const result = override(url, options)
if (result !== undefined) return result
if (url === '/api/collaboration') return { rooms: [sharedRoom], invitations: [], managedRooms: [{ id: 'admin-only' }] }
if (url === '/api/communication/chat/room-preferences') return { hiddenRoomKeys: [] }
if (url === '/api/communication/chat/rooms') return { rooms: [nativeRoom] }
if (url.endsWith('/project-rooms') || url.endsWith('/direct-rooms')) return { rooms: [] }
if (url.endsWith('/unread')) return { rooms: { allgemein: { count: 2 } } }
if (url.endsWith('/members')) return { members: [{ userId: 'member', displayName: 'Kollege' }] }
if (url.includes('/sync?')) return { messages: [] }
if (url.endsWith('/messages')) return { messages: [{ id: 1, body: 'Interne Nachricht' }] }
if (url.endsWith('/read')) return {}
throw new Error(`Unexpected request: ${url}`)
}
}
function setup(api = fixture(), query = {}) {
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
const route = vue.reactive({ query })
const calls = [], toasts = [], timers = new Map()
const listeners = new Map()
const events = {
addEventListener: (event, handler) => listeners.set(event, handler),
removeEventListener: event => listeners.delete(event),
}
const document = { hidden: false, ...events }
let mount, unmount, timerId = 0
const scope = vue.effectScope()
const context = vm.createContext({
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
useToast: () => ({ add: item => toasts.push(item) }),
useAuthStore: () => auth, useRoute: () => route,
useRouter: () => ({ replace: async location => { route.query = location.query } }),
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
window: { ...events, setInterval: handler => { timers.set(++timerId, handler); return timerId } },
clearInterval: id => timers.delete(id), document,
})
scope.run(() => vm.runInContext(source, context))
return { auth, route, calls, toasts, timers, listeners, document, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) }
}
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
const ui = setup()
t.after(ui.close)
await ui.run('refreshLists()')
assert.equal(ui.run('decoratedRooms.value.length'), 2)
assert.equal(ui.run('decoratedRooms.value.find(r => r.source === "collaboration").key'), 'collaboration:shared-room')
assert.equal(ui.run('groupedRooms.value.at(-1).label'), 'Zusammenarbeit')
assert.equal(ui.run('decoratedRooms.value.at(-1).unread'), 3)
assert.equal(ui.run('decoratedRooms.value[0].unread'), 2)
assert.equal(ui.run('decoratedRooms.value.some(r => r.id === "admin-only")'), false)
})
test('opening a collaboration deep link never provisions, reads or sends through native chat APIs', async t => {
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('isCollaboration.value'), true)
assert.equal(ui.route.query.room, 'collaboration:shared-room')
ui.run('draft.value = "Partnernachricht"')
await ui.run('pollMessages()')
await ui.run('sendMessage()')
assert.equal(ui.calls.some(({ url }) => url.startsWith('/api/communication/chat/rooms/')), false)
ui.route.query = { room: 'allgemein' }
await settle()
assert.equal(ui.run('isInternal.value'), true)
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
ui.route.query = { room: 'collaboration:shared-room' }
await settle()
assert.equal(ui.run('isCollaboration.value'), true)
assert.equal(ui.run('messages.value.length'), 0)
})
test('accepting an invitation opens the returned local binding and clears the invitation', async t => {
let accepted = false
const ui = setup(fixture(url => {
if (url === '/api/collaboration/invitations/invite/accept') { accepted = true; return { roomId: sharedRoom.id } }
if (url === '/api/collaboration') return { rooms: accepted ? [sharedRoom] : [], invitations: accepted ? [] : [{ id: 'invite' }] }
}))
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('invitations.value.length'), 1)
await ui.run('respondToInvitation("invite", true)')
assert.equal(ui.run('invitations.value.length'), 0)
assert.equal(ui.run('isCollaboration.value'), true)
assert.equal(ui.route.query.room, 'collaboration:shared-room')
assert.equal(ui.calls.find(({ url }) => url.endsWith('/accept')).options.method, 'POST')
})
test('declining an invitation keeps the current chat open', async t => {
let declined = false
const ui = setup(fixture(url => {
if (url.endsWith('/invite/decline')) { declined = true; return { ok: true } }
if (url === '/api/collaboration') return { rooms: [], invitations: declined ? [] : [{ id: 'invite' }] }
}))
t.after(ui.close)
ui.mount()
await settle()
await ui.run('respondToInvitation("invite", false)')
assert.equal(ui.run('invitations.value.length'), 0)
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
})
test('a late native room load cannot populate a collaboration room or mark it read', async t => {
const pending = deferred()
const ui = setup(fixture(url => url.endsWith('/allgemein/messages') ? pending.promise : undefined))
t.after(ui.close)
ui.mount()
await settle()
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
pending.resolve({ messages: [{ id: 99, body: 'Late private content' }] })
await settle()
assert.equal(ui.run('messages.value.length'), 0)
assert.equal(ui.run('members.value.length'), 0)
assert.equal(ui.run('roomLoading.value'), false)
assert.equal(ui.calls.some(({ url }) => url.endsWith('/read')), false)
})
test('late sync and send results cannot overwrite the selected collaboration room', async t => {
const sync = deferred(), send = deferred()
const ui = setup(fixture((url, options) => {
if (url.includes('/sync?')) return sync.promise
if (url.endsWith('/messages') && options?.method === 'POST') return send.promise
}))
t.after(ui.close)
ui.mount()
await settle()
const polling = ui.run('pollMessages()')
ui.run('draft.value = "Internal draft"')
const sending = ui.run('sendMessage()')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
const reads = ui.calls.filter(({ url }) => url.endsWith('/read')).length
sync.resolve({ messages: [{ id: 2, body: 'Late sync' }] })
send.resolve({ id: 3, body: 'Late sent message' })
await Promise.all([polling, sending])
assert.equal(ui.run('messages.value.length'), 0)
assert.equal(ui.run('draft.value'), '')
assert.equal(ui.run('sending.value'), false)
assert.equal(ui.calls.filter(({ url }) => url.endsWith('/read')).length, reads)
})
test('switching tenants clears visible content immediately and ignores older room lists', async t => {
const stale = deferred(), fresh = deferred()
let lists = 0
const ui = setup(fixture(url => {
if (url === '/api/collaboration') return ++lists === 1 ? stale.promise : fresh.promise
}))
t.after(ui.close)
ui.mount()
ui.run('messages.value = [{ id: 1, body: "Previous tenant" }]; draft.value = "Private draft"')
ui.auth.activeTenant = '2'
assert.equal(ui.run('messages.value.length'), 0)
assert.equal(ui.run('collaborationRooms.value.length'), 0)
assert.equal(ui.run('draft.value'), '')
assert.equal(ui.run('session.value'), 'user-1:2')
fresh.resolve({ rooms: [{ id: 'tenant-2' }], invitations: [] })
await settle()
stale.resolve({ rooms: [{ id: 'tenant-1-secret' }], invitations: [{ id: 'private-invite' }] })
await settle()
assert.equal(ui.run('collaborationRooms.value[0].id'), 'tenant-2')
assert.equal(ui.run('invitations.value.length'), 0)
})
test('collaboration and internal service failures do not block the other room type', async t => {
const ui = setup(fixture(url => {
if (url === '/api/collaboration') throw { statusCode: 503, data: { message: 'Unterräume nicht erreichbar' } }
}))
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('isInternal.value'), true)
assert.equal(ui.run('messages.value.length'), 1)
assert.equal(ui.run('collaborationError.value'), 'Unterräume nicht erreichbar')
const other = setup(fixture(url => {
if (url === '/api/communication/chat/rooms') throw { statusCode: 409, data: { message: 'Chat nicht verfügbar' } }
}), { room: 'collaboration:shared-room' })
t.after(other.close)
other.mount()
await settle()
assert.equal(other.run('isCollaboration.value'), true)
assert.equal(other.run('listError.value'), 'Chat nicht verfügbar')
})
test('revoked membership removes the active collaboration room instead of exposing managed rooms', async t => {
let revoked = false
const ui = setup(fixture(url => {
if (url === '/api/collaboration' && revoked) return { rooms: [], invitations: [], managedRooms: [sharedRoom] }
}), { room: 'collaboration:shared-room' })
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('isCollaboration.value'), true)
revoked = true
await ui.run('loadCollaboration()')
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.run('isCollaboration.value'), false)
assert.equal(ui.run('isInternal.value'), false)
})
test('logout clears the room and invitations without starting requests with the previous credentials', async t => {
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
t.after(ui.close)
ui.mount()
await settle()
const calls = ui.calls.length
ui.auth.user = null
await settle()
await ui.run('refreshLists()')
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.run('invitations.value.length'), 0)
assert.equal(ui.calls.length, calls)
})
test('unmount cancels both polling timers and ignores pending lists', async () => {
const pending = deferred()
const ui = setup(fixture(url => url === '/api/collaboration' ? pending.promise : undefined))
ui.mount()
assert.equal(ui.timers.size, 2)
ui.close()
pending.resolve({ rooms: [sharedRoom], invitations: [] })
await settle()
assert.equal(ui.timers.size, 0)
assert.equal(ui.listeners.size, 0)
assert.equal(ui.run('collaborationRooms.value.length'), 0)
assert.equal(ui.run('messages.value.length'), 0)
})
test('hiding the active room persists the preference, switches away and allows restoring it', async t => {
const ui = setup()
t.after(ui.close)
ui.mount()
await settle()
await ui.run('setRoomHidden(activeRoom.value, true)')
assert.equal(ui.run('hiddenRooms.value[0].key'), 'allgemein')
assert.equal(ui.run('visibleRooms.value.some(room => room.key === "allgemein")'), false)
assert.equal(ui.run('isCollaboration.value'), true)
assert.equal(ui.run('messages.value.length'), 0)
const save = ui.calls.find(call => call.url.endsWith('/room-preferences') && call.options?.method === 'PUT')
assert.equal(save.options.body.roomKey, 'allgemein')
assert.equal(save.options.body.hidden, true)
await ui.run('setRoomHidden(hiddenRooms.value[0], false)')
assert.equal(ui.run('hiddenRooms.value.length'), 0)
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
})
test('saved hidden rooms stay hidden on reload, including federated room deep links', async t => {
const ui = setup(fixture(url => url.endsWith('/room-preferences') ? { hiddenRoomKeys: ['collaboration:shared-room', 'allgemein'] } : undefined), { room: 'collaboration:shared-room' })
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('visibleRooms.value.length'), 0)
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.calls.some(({ url }) => /\/rooms\/.+\/(messages|read)/.test(url)), false)
await ui.run('selectRoom(decoratedRooms.value[0])')
assert.equal(ui.run('activeRoom.value'), null)
await ui.run('setRoomHidden(hiddenRooms.value.find(r => r.source === "collaboration"), false)')
assert.equal(ui.run('isCollaboration.value'), true)
})
test('preference save failure leaves the active conversation and list unchanged', async t => {
const ui = setup(fixture((url, options) => {
if (url.endsWith('/room-preferences') && options?.method === 'PUT') throw { data: { message: 'Speichern fehlgeschlagen' } }
}))
t.after(ui.close)
ui.mount()
await settle()
await ui.run('setRoomHidden(activeRoom.value, true)')
assert.equal(ui.run('hiddenRooms.value.length'), 0)
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
assert.equal(ui.toasts.at(-1).title, 'Speichern fehlgeschlagen')
})
test('pending hide operations cannot change a new tenant, and hiding the last room clears its content', async t => {
const pending = deferred()
const ui = setup(fixture((url, options) => {
if (url.endsWith('/room-preferences') && options?.method === 'PUT') return pending.promise
}))
t.after(ui.close)
ui.mount()
await settle()
const hiding = ui.run('setRoomHidden(activeRoom.value, true)')
ui.auth.activeTenant = '2'
await settle()
pending.resolve({ hidden: true, roomKey: 'allgemein' })
await hiding
assert.equal(ui.run('hiddenRoomKeys.value.length'), 0)
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
const single = setup(fixture(url => url === '/api/collaboration' ? { rooms: [], invitations: [] } : undefined))
t.after(single.close)
single.mount()
await settle()
await single.run('setRoomHidden(activeRoom.value, true)')
await settle()
assert.equal(single.run('activeRoom.value'), null)
assert.equal(single.run('messages.value.length'), 0)
assert.equal(single.run('members.value.length'), 0)
assert.equal(single.run('visibleRooms.value.length'), 0)
})
test('removing an active project from the permitted room list clears its messages and members', async t => {
let disabled = false
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: disabled ? [] : [{ key: 'project_1', exists: true, type: 'project' }] } : undefined), { room: 'project_1' })
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('messages.value.length'), 1)
disabled = true
await ui.run('loadRoomLists()')
await settle()
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.run('messages.value.length'), 0)
assert.equal(ui.run('members.value.length'), 0)
})
test('returning to chat immediately reflects a disabled or re-enabled project without waiting for polling', async t => {
let disabled = false
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: disabled ? [] : [{ key: 'project_1', exists: true, type: 'project' }] } : undefined), { room: 'project_1' })
t.after(ui.close)
ui.mount()
await settle()
disabled = true
ui.document.hidden = true
ui.listeners.get('visibilitychange')()
await settle()
assert.equal(ui.run('activeRoom.value.key'), 'project_1')
ui.document.hidden = false
ui.listeners.get('visibilitychange')()
await settle()
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.run('messages.value.length'), 0)
disabled = false
ui.listeners.get('focus')()
await settle()
assert.equal(ui.run('groupedRooms.value.find(group => group.label === "Projekte").rooms[0].key'), 'project_1')
})
test('native image-only sends retain the draft after an error and clear it after success', async t => {
let fail = true
const ui = setup(fixture((url, options) => {
if (url.endsWith('/messages') && options?.method === 'POST') {
if (fail) throw { status: 503, data: { message: 'Offline' } }
return { id: 2, body: '', image: { name: 'Bild', width: 80, height: 40 }, own: true }
}
}))
t.after(ui.close)
await ui.run('initialize()')
ui.run('draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Bild" }')
await ui.run('sendMessage()')
assert.equal(ui.run('draftImage.value.name'), 'Bild')
fail = false
await ui.run('sendMessage()')
assert.equal(ui.run('messages.value.at(-1).image.name'), 'Bild')
assert.equal(ui.run('draftImage.value'), null)
assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild'))
})

View File

@@ -0,0 +1,91 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
const settle = () => new Promise(setImmediate)
const deferred = () => { let resolve; return { promise: new Promise(done => { resolve = done }), resolve: value => resolve(value) } }
function setup(component, values, extra = {}) {
const props = vue.reactive(values), events = [], scope = vue.effectScope()
let unmount = () => {}
const source = fs.readFileSync(`${__dirname}/../components/${component}.vue`, 'utf8').match(/<script setup>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/mg, '')
const context = vm.createContext({ ref: vue.ref, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
defineProps: () => props, defineEmits: () => (name, value) => {
events.push({ name, value })
if (name === 'update:modelValue') props.modelValue = value
if (name === 'update:image') props.image = value
}, onMounted: () => {}, onBeforeUnmount: handler => { unmount = handler }, clearInterval: () => {}, document: { hidden: false }, ...extra })
scope.run(() => vm.runInContext(source, context))
return { props, events, run: code => vm.runInContext(code, context), close: () => { unmount(); scope.stop() } }
}
test('composer inserts emoji at selection and sends image-only messages while respecting disabled state', async t => {
const ui = setup('ChatComposer', { modelValue: 'Hallo Welt', image: null, busy: false, disabled: false }, { prepareChatImage: async file => ({ dataUrl: 'data:image/webp;base64,AAAA', name: file.name }) })
t.after(ui.close)
ui.run('textarea.value = { selectionStart: 6, selectionEnd: 10, focus() {}, setSelectionRange() {} }')
await ui.run('insertEmoji("👋")')
assert.equal(ui.props.modelValue, 'Hallo 👋')
ui.props.modelValue = ''
ui.run('send()')
assert.equal(ui.events.some(e => e.name === 'send'), false)
await ui.run('attach({ name: "Bild.png" })')
ui.run('send()')
assert.equal(ui.events.filter(e => e.name === 'send').length, 1)
ui.props.disabled = true
ui.run('send()')
assert.equal(ui.events.filter(e => e.name === 'send').length, 1)
})
test('a late image conversion cannot attach a draft after the room unmounts', async () => {
const pending = deferred()
const ui = setup('ChatComposer', { modelValue: '', image: null, busy: false, disabled: false }, { prepareChatImage: () => pending.promise })
const attaching = ui.run('attach({ name: "Bild.png" })')
assert.equal(ui.run('preparing.value'), true)
ui.close()
pending.resolve({ dataUrl: 'private image', name: 'Bild.png' })
await attaching
assert.equal(ui.props.image, null)
assert.equal(ui.events.length, 0)
})
test('reaction refresh batches messages and stale poll results cannot overwrite an own reaction', async t => {
const pending = deferred(), calls = []
let delay = false
const ui = setup('ChatReactions', { endpoint: '/room/a', messages: [{ id: 1 }, { id: 2, pending: true }], disabled: false,
api: async (url, options) => {
calls.push({ url, options })
if (options?.method === 'PUT') return { reactions: { 1: [{ emoji: '👍', count: 1, own: true }] } }
if (delay) return pending.promise
return { reactions: {} }
} })
t.after(ui.close)
await settle()
assert.equal(calls[0].url, '/room/a/reactions?ids=1')
delay = true
const polling = ui.run('refresh()')
await ui.run('react(1, "👍")')
pending.resolve({ reactions: {} })
await polling
assert.equal(ui.run('reactions.value[1][0].own'), true)
assert.equal(calls.find(c => c.options?.method === 'PUT').options.body.active, true)
ui.props.disabled = true
const count = calls.length
await ui.run('react(1, "👍")')
assert.equal(calls.length, count)
})
test('image display rejects external URLs and ignores responses after a room change', async t => {
const pending = deferred()
const ui = setup('ChatImage', { endpoint: '/room/a', message: { id: 1 }, api: () => pending.promise })
t.after(ui.close)
ui.props.endpoint = '/room/b'
await vue.nextTick()
pending.resolve({ dataUrl: 'https://tracking.example/image.png' })
await settle()
assert.equal(ui.run('dataUrl.value'), '')
assert.equal(ui.run('error.value'), 'Bild derzeit nicht verfügbar.')
ui.props.api = async () => ({ dataUrl: 'data:image/webp;base64,AAAA' })
await ui.run('load()')
assert.equal(ui.run('dataUrl.value'), 'data:image/webp;base64,AAAA')
})

View File

@@ -167,6 +167,84 @@ test('filing failure preserves the document and selected values for retry', asyn
assert.equal(ui.run('canFile.value'), true)
})
test('archiving needs no classification, waits for confirmation and updates the list, preview and badge', async t => {
const pending = deferred()
const staleList = deferred()
const requests = []
let listings = 0
const ui = setup((url, options) => {
requests.push({ url, options })
if (url.endsWith('/archive')) return pending.promise
if (url.endsWith('/filing-options')) return Promise.resolve({ folders: [], types: [] })
if (url.endsWith('/suggestion')) return Promise.resolve({ status: 'pending', suggestion: null })
if (++listings === 1) return staleList.promise
return Promise.resolve({ documents: [{ id: 'remaining' }], total: 50 })
})
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 51; page.value = 2')
await ui.run('openPreview(documents.value[0])')
await settle()
assert.equal(ui.run('canFile.value'), false)
ui.run('openArchive(documents.value[0])')
assert.equal(requests.some(request => request.url.endsWith('/archive')), false)
const listing = ui.run('load(true)')
const saving = ui.run('archiveDocument()')
await ui.run('archiveDocument()')
await ui.run('fileDocument()')
assert.equal(requests.filter(request => request.url.endsWith('/archive')).length, 1)
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
assert.equal(ui.run('documents.value.length'), 1)
pending.resolve({ id: 'scan', archived: true })
await saving
staleList.resolve({ documents: [{ id: 'scan' }], total: 51 })
await listing
assert.equal(requests.find(request => request.url.endsWith('/archive')).options.method, 'POST')
assert.equal(ui.run('archiveOpen.value'), false)
assert.equal(ui.run('previewOpen.value'), false)
assert.equal(ui.run('documents.value[0].id'), 'remaining')
assert.equal(ui.run('total.value'), 50)
assert.equal(ui.run('page.value'), 1)
assert.equal(ui.countRefreshes(), 1)
})
test('archive cancellation and failure preserve the document and allow a retry from the list', async t => {
let calls = 0
const ui = setup(async () => { calls++; throw { data: { message: 'Archivieren fehlgeschlagen' } } })
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1; openArchive(documents.value[0])')
await settle()
ui.run('archiveOpen.value = false')
await settle()
assert.equal(ui.run('archiveTarget.value'), null)
assert.equal(calls, 0)
ui.run('openArchive(documents.value[0])')
await ui.run('archiveDocument()')
assert.equal(ui.run('archiveError.value'), 'Archivieren fehlgeschlagen')
assert.equal(ui.run('archiveOpen.value'), true)
assert.equal(ui.run('archiveSaving.value'), false)
assert.equal(ui.run('documents.value[0].id'), 'scan')
assert.equal(ui.run('total.value'), 1)
assert.equal(ui.countRefreshes(), 0)
})
test('archive responses from a previous tenant or unmounted page cannot update the current view', async t => {
for (const action of ['tenant', 'unmount']) {
const pending = deferred()
const ui = setup(url => url.endsWith('/archive') ? pending.promise : Promise.resolve({ documents: [{ id: 'tenant-2' }], total: 1 }))
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1; openArchive(documents.value[0])')
const saving = ui.run('archiveDocument()')
if (action === 'tenant') ui.auth.activeTenant = '2'
else ui.close()
await settle()
pending.resolve({ id: 'scan', archived: true })
await saving
assert.equal(ui.run('documents.value[0].id'), action === 'tenant' ? 'tenant-2' : 'scan')
assert.equal(ui.countRefreshes(), 0)
if (action === 'tenant') assert.equal(ui.run('archiveOpen.value'), false)
}
})
test('tenant switching discards pending folder options and filing responses', async t => {
for (const stage of ['options', 'save']) {
const pending = deferred()

View File

@@ -0,0 +1,62 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const { renderToString } = require('@vue/server-renderer')
const { parse, compileScript } = require('@vue/compiler-sfc')
function component(filename, globals = {}) {
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'), { filename })
const compiled = compileScript(descriptor, { id: filename, inlineTemplate: true }).content
const source = ts.transpileModule(compiled, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText
const context = vm.createContext({ exports: {}, require, ...globals })
vm.runInContext(source, context)
return context.exports.default
}
// Resolve the configured renderer, so accidentally wiring the unrelated "active" renderer fails this test.
const storeSource = fs.readFileSync(`${__dirname}/../stores/data.js`, 'utf8')
const store = ts.createSourceFile('data.js', storeSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
let chatColumn
function visit(node) {
if (ts.isObjectLiteralExpression(node) && node.properties.some(property =>
ts.isPropertyAssignment(property) && property.name.getText(store) === 'key' && property.initializer.getText(store) === '"chatEnabled"')) chatColumn = node
ts.forEachChild(node, visit)
}
visit(store)
const rendererName = chatColumn.properties.find(property => property.name?.getText(store) === 'component').initializer.getText(store)
const rendererImport = store.statements.find(statement => ts.isImportDeclaration(statement) && statement.importClause?.name?.text === rendererName)
const renderer = component(path.resolve(__dirname, '..', rendererImport.moduleSpecifier.text.replace(/^~\//, '')))
const column = vm.runInNewContext(`(${chatColumn.getText(store)})`, { [rendererName]: renderer })
test('project list renders the actual chat setting, independently of an unrelated active flag', async () => {
for (const [row, expected] of [
[{ chatEnabled: true }, 'Ja'],
[{ chatEnabled: true, active: false }, 'Ja'],
[{ chatEnabled: false, active: true }, 'Nein'],
[{ chatEnabled: false }, 'Nein'],
[{ active: true }, '–'],
]) {
const html = await renderToString(vue.createSSRApp(column.component, { row }))
assert.match(html, new RegExp(`>${expected}</span>`), JSON.stringify(row))
}
})
test('project information shows Ja and Nein after loading the saved setting', async () => {
const information = component(`${__dirname}/../components/EntityShowSubInformation.vue`, {
useDataStore: () => ({ dataTypes: { projects: { templateColumns: [column] } } }),
useTempStore: () => ({}), useRouter: () => ({}),
})
for (const chatEnabled of [true, false]) {
const app = vue.createSSRApp(information, { item: { id: 1, chatEnabled }, topLevelType: 'projects', platform: 'default' })
app.component('UCard', { setup: (_, { slots }) => () => vue.h('section', slots.default?.()) })
app.component('UAlert', { render: () => null })
app.component('TelephonyCallButton', { render: () => null })
const html = await renderToString(app)
assert.match(html, /Projektchat aktiviert/)
assert.match(html, new RegExp(`>${chatEnabled ? 'Ja' : 'Nein'}</span>`))
}
})

View File

@@ -0,0 +1,73 @@
// Run against a built frontend with PLAYWRIGHT_MODULE pointing to an installed Playwright package.
// All API calls are isolated fixtures; no real account or application data is used.
import assert from 'node:assert/strict'
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright')
const base = process.env.TIME_V2_FRONTEND_URL || 'http://127.0.0.1:3148'
const browser = await chromium.launch({headless:true})
const page = await browser.newPage({viewport:{width:1440,height:1100}})
const errors = []
page.on('pageerror',e => errors.push(e.message))
const user = '11111111-1111-4111-8111-111111111111'
const month = new Date().toISOString().slice(0,7)
const start = `${month}-01T06:00:00.000Z`
let item = {id:'22222222-2222-4222-8222-222222222222',user_id:user,type:'work',description:'Test Anwesenheit',started_at:start,
ended_at:`${month}-01T14:30:00.000Z`,date_from:null,date_to:null,timezone:'Europe/Berlin',status:'factual',version:1,
breaks:[{started_at:`${month}-01T10:00:00.000Z`,ended_at:`${month}-01T10:30:00.000Z`}],projects:[]}
const profile={id:user,user_id:user,full_name:'Anna Beispiel',first_name:'Anna',last_name:'Beispiel',pinned_on_navigation:[],temp_config:{},role:{rights:[]}}
const tenant={id:1,name:'Testbetrieb',extraModules:[],features:{},locked:'none'}
const commands=[]
await page.addInitScript(() => localStorage.setItem('token','fixture-only'))
await page.route('**/*',async route => {
const url=new URL(route.request().url())
if(url.origin!==new URL(base).origin) return route.abort()
if(!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/auth/')) return route.continue()
let body=[]
if(url.pathname==='/api/me') body={user:{id:user,email:'anna@example.test',is_admin:true},profile,permissions:[],tenants:[tenant],activeTenant:1}
else if(url.pathname==='/api/tenant') body=tenant
else if(url.pathname==='/api/tenant/profiles') body={data:[profile]}
else if(url.pathname==='/api/v2/time/context') body={profiles:[{...profile,activated_at:start}],projects:[{id:1,name:'Projekt Nord'}],permissions:{readAll:true,writeAll:true,approve:true,activate:true}}
else if(url.pathname==='/api/v2/time/spans') body={items:[item],active:item.ended_at ? null : item,account:{activated_at:start}}
else if(url.pathname==='/api/v2/time/evaluation') body={summary:{timeSpanWorkingMinutes:480,presenceMinutes:510,breakMinutes:30,sumWorkingMinutesApproved:480,sumWorkingMinutesSubmitted:480,saldoApproved:0,saldoSubmitted:0,sumVacationDays:0,sumSickDays:0,sumRecreationDays:0}}
else if(url.pathname==='/api/v2/time/commands') {
const data=route.request().postDataJSON();commands.push(data)
const c=data.command
if(c.action==='update') item={...item,...c.data,status:'factual',version:item.version+1}
else if(c.action==='submit') item={...item,status:'submitted',version:item.version+1}
else if(c.action==='approve') item={...item,status:'approved',version:item.version+1}
body=item
}
else if(url.pathname.endsWith('/history')) body=[{id:'history-1',span_id:item.id,actor_id:user,action:'create',reason:'',before_data:null,after_data:item,created_at:start}]
return route.fulfill({contentType:'application/json',body:JSON.stringify(body)})
})
try {
await page.goto(`${base}/v2/time`,{waitUntil:'networkidle'})
await page.getByRole('button',{name:'Manuell erfassen',exact:true}).waitFor()
await page.getByRole('button',{name:'Zeitstrahl',exact:true}).click()
await page.getByRole('img',{name:'Anwesenheit mit 1 Pausen'}).waitFor()
await page.getByRole('button',{name:'Bearbeiten',exact:true}).click()
await page.getByLabel('Beschreibung',{exact:true}).fill('Korrigierte Anwesenheit')
await page.getByLabel('Korrekturgrund',{exact:true}).fill('Notiz korrigiert')
await page.getByRole('button',{name:'Speichern',exact:true}).click()
await page.getByText('Zeit gespeichert.',{exact:true}).waitFor()
assert.equal(commands.at(-1).command.data.breaks.length,1)
assert.equal(commands.at(-1).command.version,1)
assert.equal(commands.at(-1).command.reason,'Notiz korrigiert')
await page.getByRole('button',{name:'Einreichen',exact:true}).click()
await page.getByRole('button',{name:'Genehmigen',exact:true}).click()
await page.getByRole('button',{name:'Verlauf',exact:true}).click()
await page.getByText('Verlauf ·',{exact:false}).waitFor()
await page.getByRole('button',{name:'Schließen',exact:true}).click()
await page.getByRole('button',{name:'Auswertung',exact:true}).click()
await page.getByText('Saldo genehmigt',{exact:true}).waitFor()
await page.screenshot({path:process.env.TIME_V2_SCREENSHOT_DESKTOP || '/tmp/fedeo-time-v2-desktop.png',fullPage:true})
await page.setViewportSize({width:390,height:844})
await page.getByRole('button',{name:'Meine Zeiten',exact:true}).click()
await page.getByRole('button',{name:'Manuell erfassen',exact:true}).click()
await page.getByLabel(/^Art/).selectOption('vacation')
await page.getByLabel('Ganztägig',{exact:true}).check()
await page.getByLabel('Erster Tag',{exact:true}).waitFor()
await page.screenshot({path:process.env.TIME_V2_SCREENSHOT_MOBILE || '/tmp/fedeo-time-v2-mobile.png',fullPage:true})
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth>window.innerWidth),false,'Mobile layout overflows')
assert.deepEqual(errors,[],'Browser runtime errors')
console.log('Time 2.0 browser checks passed: editing with pauses, submission, approval, history, evaluation, mobile absence form.')
} catch (error) { await page.screenshot({path:'/tmp/fedeo-time-v2-error.png',fullPage:true}); throw error } finally {await browser.close()}

View File

@@ -0,0 +1,46 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
function loadUtility(name) {
const source = fs.readFileSync(`${__dirname}/../utils/${name}.ts`, 'utf8')
const { outputText } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 },
})
const exports = {}
vm.runInNewContext(outputText, { exports })
return exports
}
const { timeSpanInput } = loadUtility('timeSpanInput')
const { staffTimeRows, workingMinutes } = loadUtility('staffTimeSpans')
test('editing repeated local times preserves the original instant and rejects ambiguous new inputs', () => {
assert.equal(timeSpanInput('2026-10-25T02:15', 'Europe/Berlin', '2026-10-25T01:15:00.000Z'), '2026-10-25T01:15:00.000Z');
assert.equal(timeSpanInput('2026-10-25T02:15', 'Europe/Berlin', '2026-10-25T00:15:00.000Z'), '2026-10-25T00:15:00.000Z');
assert.throws(() => timeSpanInput('2026-10-25T02:15', 'Europe/Berlin'), /zweimal/);
assert.throws(() => timeSpanInput('2026-03-29T02:15', 'Europe/Berlin'), /existiert nicht/);
assert.equal(timeSpanInput('2026-10-25T01:15', 'UTC'), '2026-10-25T01:15:00.000Z');
});
test('dashboard keeps a paused attendance active and freezes its net working duration', () => {
const attendance = {
id: 'attendance-1', user_id: 'user-1', type: 'work', status: 'factual',
started_at: '2026-10-01T06:00:00Z', ended_at: null,
breaks: [{ started_at: '2026-10-01T10:00:00Z', ended_at: null }],
}
const base = { id: attendance.id, model: 'spans-v2', attendance }
const segments = [
{ ...base, type: 'work', startedAt: attendance.started_at, endedAt: attendance.breaks[0].started_at },
{ ...base, type: 'pause', startedAt: attendance.breaks[0].started_at, endedAt: null },
]
const entries = staffTimeRows(segments, attendance.user_id, Date.parse('2026-10-01T11:00:00Z'))
assert.equal(entries.length, 1)
assert.equal(entries[0].type, 'work')
assert.equal(entries[0].paused, true)
assert.equal(entries[0].stopped_at, null)
assert.equal(entries[0].duration_minutes, 240)
assert.equal(workingMinutes(entries[0], Date.parse('2026-10-01T12:00:00Z')), 240)
})

View File

@@ -0,0 +1,16 @@
export async function prepareChatImage(file) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) throw new Error('Bitte ein JPEG-, PNG- oder WebP-Bild auswählen.')
if (file.size > 10 * 1024 * 1024) throw new Error('Das Originalbild darf höchstens 10 MB groß sein.')
const bitmap = await createImageBitmap(file)
try {
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height))
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(bitmap.width * scale)); canvas.height = Math.max(1, Math.round(bitmap.height * scale))
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
for (const quality of [0.85, 0.7, 0.55, 0.4]) {
const dataUrl = canvas.toDataURL('image/webp', quality)
if (dataUrl.length <= 480_000) return { dataUrl, name: (file.name || 'Bild').slice(0, 200) }
}
throw new Error('Dieses Bild ist zu groß. Bitte einen kleineren Ausschnitt verwenden.')
} finally { bitmap.close() }
}

View File

@@ -0,0 +1,32 @@
/** Collapse legacy-compatible work segments into complete persisted attendances for widgets. */
export function staffTimeRows(spans: any[], userId: string, now = Date.now()) {
const seen = new Set<string>();
return (spans || []).filter(span => {
if (span.model !== 'spans-v2') return true;
if (seen.has(span.id)) return false;
seen.add(span.id);
return true;
}).map(span => {
const s = span.attendance;
if (s) {
const entry = {
id: s.id, eventIds: [s.id], state: s.status, started_at: s.started_at,
stopped_at: s.ended_at, user_id: s.user_id, type: s.type, description: s.description,
breaks: s.breaks, paused: s.breaks.some((p: any) => !p.ended_at), model: 'spans-v2',
};
return { ...entry, duration_minutes: workingMinutes(entry, now) };
}
return {
id: span.sourceEventIds?.[0] || null, eventIds: span.sourceEventIds || [], state: span.status,
started_at: span.startedAt, stopped_at: span.endedAt, user_id: userId, type: span.type,
description: span.description || span.payload?.description || '',
duration_minutes: Math.floor(((span.endedAt ? Date.parse(span.endedAt) : now) - Date.parse(span.startedAt)) / 60000),
};
}).sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at));
}
export function workingMinutes(entry: any, now = Date.now()) {
if (!entry.started_at) return 0;
const end = entry.stopped_at ? Date.parse(entry.stopped_at) : now;
const pauses = (entry.breaks || []).reduce((total: number, p: any) => total + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0);
return Math.max(0, Math.floor((end - Date.parse(entry.started_at) - pauses) / 60000));
}

View File

@@ -0,0 +1,29 @@
/** Use Intl directly: converting repeated hours through a local Date loses their UTC offset. */
export function localTimeInput(value: string, zone = 'Europe/Berlin') {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(new Date(value));
const get = (type: string) => parts.find(part => part.type === type)!.value;
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`;
}
/** Preserve existing instants. Require an explicit UTC input for ambiguous new local times. */
export function timeSpanInput(value: string, zone: string, original?: string | null) {
if (original && localTimeInput(original, zone) === value) return original;
const wall = Date.parse(`${value}:00Z`);
if (!Number.isFinite(wall)) throw new Error('Ungültiger Zeitpunkt.');
// Sample both sides of a transition; supports non-hour offsets and 30-minute DST changes.
const offsets = new Set<number>();
for (let hours = -36; hours <= 36; hours += 6) {
const sample = wall + hours * 3600000;
offsets.add(Date.parse(`${localTimeInput(new Date(sample).toISOString(), zone)}:00Z`) - sample);
}
const matches = [...offsets].map(offset => wall - offset)
.filter(candidate => localTimeInput(new Date(candidate).toISOString(), zone) === value);
if (!matches.length) throw new Error('Diese Ortszeit existiert nicht. Bitte Zeitumstellung beachten.');
if (matches.length > 1) {
throw new Error('Diese Ortszeit kommt zweimal vor. Bitte Zeitzone UTC wählen und den eindeutigen UTC-Zeitpunkt eingeben.');
}
return new Date(matches[0]).toISOString();
}

View File

@@ -39,6 +39,8 @@ function getStateLabel(state: string): string {
}
function getTypeLabel(type: string): string {
if (type === 'pause') return 'Pause';
if (type === 'overtime_compensation') return 'Freizeitausgleich';
if (type === 'vacation') return 'Urlaub';
if (type === 'sick') return 'Krankheit';
if (type === 'holiday') return 'Feiertag';
@@ -133,6 +135,17 @@ export default function TimeTrackingScreen() {
}
}, [active, currentUserId, token, load]);
async function onPause() {
if (!token || !currentUserId || !active) return;
setActionLoading(true); setError(null);
try {
await createStaffTimeEvent(token, { eventtype: active.paused ? 'pause_end' : 'pause_start',
eventtime: new Date().toISOString(), user_id: currentUserId });
await load(false);
} catch (err) { setError(err instanceof Error ? err.message : 'Pause konnte nicht gespeichert werden.'); }
finally { setActionLoading(false); }
}
async function onSubmit(entry: StaffTimeSpan) {
if (!token || !entry.eventIds?.length) return;
setActionLoading(true);
@@ -206,10 +219,15 @@ export default function TimeTrackingScreen() {
<View style={styles.statusCard}>
<Text style={styles.statusLabel}>Aktive Zeit</Text>
<Text style={styles.statusValue}>
{active ? `Läuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
{active ? `${active.paused ? 'In Pause' : 'Läuft'} seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
</Text>
<View style={styles.statusActions}>
{active?.model === 'spans-v2' && active.type === 'work' ? (
<Pressable style={[styles.startButton, actionLoading ? styles.buttonDisabled : null]} onPress={onPause} disabled={actionLoading}>
<Text style={styles.startButtonText}>{active.paused ? 'Pause beenden' : 'Pause beginnen'}</Text>
</Pressable>
) : null}
{active ? (
<Pressable
style={[styles.stopButton, actionLoading ? styles.buttonDisabled : null]}

View File

@@ -15,6 +15,8 @@ import {
} from '@/src/lib/server-config';
import { useAuth } from '@/src/providers/auth-provider';
import { NotificationPreferences } from '@/src/components/NotificationPreferences';
const PRIMARY = '#69c350';
function isValidServerUrl(value: string): boolean {
@@ -153,6 +155,7 @@ export default function SettingsScreen() {
return (
<ScrollView contentContainerStyle={styles.container}>
{token && <NotificationPreferences key={String(activeTenantId)} token={token} />}
<View style={styles.card}>
<Text style={styles.title}>Server-Instanz</Text>
<Text style={styles.hint}>

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { Pressable, Switch, Text, View } from 'react-native';
import { fetchNotificationPreferences, saveNotificationPreferences, type NotificationPreferenceEvent } from '@/src/lib/api';
export function NotificationPreferences({ token }: { token: string }) {
const [events, setEvents] = useState<NotificationPreferenceEvent[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => {
let active = true;
setLoading(true);
fetchNotificationPreferences(token).then(result => { if (active) setEvents(result.events); })
.catch(error => { if (active) setMessage(error.message); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [token]);
async function save() {
setSaving(true);
setMessage('');
try { await saveNotificationPreferences(token, events); setMessage('Einstellungen gespeichert.'); }
catch (error) { setMessage(error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'); }
finally { setSaving(false); }
}
return <View style={{ gap: 12, padding: 16, backgroundColor: '#fff', borderRadius: 12 }}>
<Text style={{ fontSize: 18, fontWeight: '600' }}>Meine Benachrichtigungen</Text>
<Text>Gilt nur für dein Profil im aktuell ausgewählten Mandanten. E-Mail geht an deine Konto-Adresse, Push an deine registrierten Geräte.</Text>
{loading && <Text>Einstellungen werden geladen …</Text>}
{!!message && <Text accessibilityRole="alert">{message}</Text>}
{!loading && events.map(event => <View key={event.eventKey} style={{ gap: 4 }}>
<Text style={{ fontWeight: '600' }}>{event.displayName}</Text>
<Text>{event.description}</Text>
{(['email', 'push'] as const).filter(channel => channel in event.channels).map(channel => <View key={channel} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text>{channel === 'email' ? 'E-Mail' : 'Push'}</Text>
<Switch accessibilityLabel={`${event.displayName}: ${channel}`} disabled={saving} value={event.channels[channel] === true}
onValueChange={enabled => setEvents(rows => rows.map(row => row.eventKey === event.eventKey ? { ...row, channels: { ...row.channels, [channel]: enabled } } : row))} />
</View>)}
</View>)}
{!loading && events.length > 0 && <Pressable accessibilityRole="button" disabled={saving} onPress={save} style={{ padding: 12, backgroundColor: '#69c350', borderRadius: 8 }}>
<Text>{saving ? 'Speichert …' : 'Speichern'}</Text>
</Pressable>}
</View>;
}

View File

@@ -34,6 +34,8 @@ export type TenantProfile = {
};
export type StaffTimeSpan = {
paused?: boolean;
model?: string;
id: string | null;
eventIds: string[];
state: string;
@@ -608,8 +610,22 @@ export async function fetchStaffTimeSpans(
const query = targetUserId ? `?targetUserId=${encodeURIComponent(targetUserId)}` : '';
const spans = await apiRequest<any[]>(`/api/staff/time/spans${query}`, { token });
return (spans || [])
const seen = new Set<string>();
return (spans || []).filter((span) => {
if (span.model !== 'spans-v2') return true;
if (seen.has(span.id)) return false;
seen.add(span.id); return true;
})
.map((span) => {
if (span.attendance) {
const s = span.attendance;
const end = s.ended_at ? Date.parse(s.ended_at) : Date.now();
const pauseMs = s.breaks.reduce((n: number, p: any) => n + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0);
return { id: s.id, eventIds: [s.id], state: s.status, started_at: s.started_at,
stopped_at: s.ended_at, duration_minutes: Math.floor((end - Date.parse(s.started_at) - pauseMs) / 60000),
user_id: s.user_id, type: s.type, description: s.description,
paused: s.breaks.some((p: any) => !p.ended_at), model: 'spans-v2' } as StaffTimeSpan;
}
const started = span.startedAt ? new Date(span.startedAt) : null;
const ended = span.endedAt ? new Date(span.endedAt) : new Date();
const durationMinutes =
@@ -1301,3 +1317,17 @@ export async function uploadPlantFile(
return parsed as ProjectFile;
}
export type NotificationPreferenceEvent = {
eventKey: string;
displayName: string;
description: string | null;
channels: Partial<Record<'email' | 'push', boolean>>;
};
export async function fetchNotificationPreferences(token: string) {
return apiRequest<{ tenantId: number; events: NotificationPreferenceEvent[] }>('/api/notifications/preferences', { token });
}
export async function saveNotificationPreferences(token: string, events: NotificationPreferenceEvent[]) {
return apiRequest('/api/notifications/preferences', { token, method: 'PUT', body: events.flatMap(event =>
Object.entries(event.channels).map(([channel, enabled]) => ({ eventType: event.eventKey, channel, enabled }))) });
}

View File

@@ -82,14 +82,14 @@ async function sendTestPush() {
});
await Promise.all([refreshInstance(), refreshJobs(), refreshDevices()]);
toast.add({
title: "Test-Push gesendet",
description: `${result.sent ?? 0} gesendet, ${result.failed ?? 0} fehlgeschlagen`,
color: result.failed ? "warning" : "success",
title: result.failed ? (result.sent ? "Test-Push teilweise fehlgeschlagen" : "Test-Push fehlgeschlagen") : "Test-Push gesendet",
description: [`${result.sent ?? 0} gesendet, ${result.failed ?? 0} fehlgeschlagen`, result.lastErrorCode, result.lastErrorMessage].filter(Boolean).join(" · "),
color: result.failed ? (result.sent ? "warning" : "error") : "success",
});
} catch (error: any) {
toast.add({
title: "Test-Push fehlgeschlagen",
description: error?.data?.message || error?.message || "Die Testnachricht konnte nicht gesendet werden.",
description: error?.data?.message || error?.data?.error || error?.message || "Die Testnachricht konnte nicht gesendet werden.",
color: "error",
});
} finally {
@@ -246,6 +246,7 @@ async function sendTestPush() {
<th>Angenommen</th>
<th>Gesendet</th>
<th>Fehler</th>
<th>Letzter Fehler</th>
<th>Erstellt</th>
</tr>
</thead>
@@ -256,6 +257,10 @@ async function sendTestPush() {
<td>{{ job.acceptedCount }}</td>
<td>{{ job.sentCount }}</td>
<td>{{ job.failedCount }}</td>
<td>
<div>{{ job.lastErrorCode || "-" }}</div>
<div v-if="job.lastErrorMessage" class="text-sm text-gray-500">{{ job.lastErrorMessage }}</div>
</td>
<td>{{ new Date(job.createdAt).toLocaleString() }}</td>
</tr>
</tbody>

View File

@@ -420,6 +420,40 @@ class UpdateTests(unittest.TestCase):
self.assertLess(next(i for i, a in enumerate(calls) if 'pull' in a), next(i for i, a in enumerate(calls) if 'stop' in a))
self.assertLess(next(i for i, a in enumerate(calls) if 'pg_restore' in a), next(i for i, a in enumerate(calls) if 'migrate' in a))
def use_pre_federation_installation(self, extra_env=b''):
keys = (b'FEDEO_PUBLIC_URL', b'FEDEO_FEDERATION_URL', b'FEDEO_FEDERATION_ENABLED')
self.original_env = b''.join(line for line in self.original_env.splitlines(True)
if not line.startswith(keys)) + extra_env
self.original_compose = b''.join(line for line in self.original_compose.splitlines(True)
if not line.lstrip().startswith(keys))
(self.root / '.env').write_bytes(self.original_env)
(self.root / 'docker-compose.yml').write_bytes(self.original_compose)
def test_update_adds_federation_compose_defaults_without_requiring_new_env_settings(self):
self.use_pre_federation_installation()
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')
env = self.updater.candidate['services']['backend']['environment']
self.assertEqual(env['FEDEO_FEDERATION_ENABLED'], 'auto')
self.assertEqual(env['API_BASE_URL'], 'https://app.example.com/backend')
self.assertEqual(env['FEDEO_FEDERATION_URL'], '')
self.assertEqual(env['FEDEO_PUBLIC_URL'], '')
self.assertEqual((self.root / '.env').read_bytes(), u.set_version(self.original_env, '3.1.0'))
self.assertIn(b'FEDEO_FEDERATION_ENABLED', (self.root / 'docker-compose.yml').read_bytes())
def test_update_preserves_explicit_federation_opt_out_and_url_overrides(self):
self.use_pre_federation_installation(
b'\nFEDEO_FEDERATION_ENABLED=false\n'
b'FEDEO_FEDERATION_URL=https://gateway.example/backend\n'
b'FEDEO_PUBLIC_URL=https://portal.example\n')
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')
env = self.updater.candidate['services']['backend']['environment']
self.assertEqual(env['FEDEO_FEDERATION_ENABLED'], 'false')
self.assertEqual(env['FEDEO_FEDERATION_URL'], 'https://gateway.example/backend')
self.assertEqual(env['FEDEO_PUBLIC_URL'], 'https://portal.example')
self.assertEqual((self.root / '.env').read_bytes(), u.set_version(self.original_env, '3.1.0'))
def test_check_only_creates_a_plan_and_ignores_unpublished_releases(self):
result = self.updater.execute('check')
self.assertEqual(result['version'], '3.1.0')