diff --git a/backend/db/migrations/0079_notification_matrix.sql b/backend/db/migrations/0079_notification_matrix.sql new file mode 100644 index 00000000..b415c18f --- /dev/null +++ b/backend/db/migrations/0079_notification_matrix.sql @@ -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'); diff --git a/backend/db/migrations/0080_staff_time_spans.sql b/backend/db/migrations/0080_staff_time_spans.sql new file mode 100644 index 00000000..937edf82 --- /dev/null +++ b/backend/db/migrations/0080_staff_time_spans.sql @@ -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_atNEW.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(); diff --git a/backend/db/migrations/0081_chat_media_reactions.sql b/backend/db/migrations/0081_chat_media_reactions.sql new file mode 100644 index 00000000..d3cc3b58 --- /dev/null +++ b/backend/db/migrations/0081_chat_media_reactions.sql @@ -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) +); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 7d2076b4..4185dade 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -533,6 +533,27 @@ "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 } ] } diff --git a/backend/db/schema/chat_reactions.ts b/backend/db/schema/chat_reactions.ts new file mode 100644 index 00000000..d627e29c --- /dev/null +++ b/backend/db/schema/chat_reactions.ts @@ -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`) })) diff --git a/backend/db/schema/collaboration.ts b/backend/db/schema/collaboration.ts index 1ffcc9ca..fe89f2f1 100644 --- a/backend/db/schema/collaboration.ts +++ b/backend/db/schema/collaboration.ts @@ -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(), diff --git a/backend/db/schema/communication_messages.ts b/backend/db/schema/communication_messages.ts index f25548e4..b06f0174 100644 --- a/backend/db/schema/communication_messages.ts +++ b/backend/db/schema/communication_messages.ts @@ -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(), diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index a5ed364c..9eaaabe9 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -105,3 +105,6 @@ export * from "./scanner_inbox" export * from "./collaboration"; export * from "./collaboration_federation" +export * from './staff_time_spans' + +export * from "./chat_reactions" diff --git a/backend/db/schema/serialexecutions.ts b/backend/db/schema/serialexecutions.ts index c63cfb22..08bed4c9 100644 --- a/backend/db/schema/serialexecutions.ts +++ b/backend/db/schema/serialexecutions.ts @@ -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" diff --git a/backend/db/schema/staff_time_spans.ts b/backend/db/schema/staff_time_spans.ts new file mode 100644 index 00000000..87bf2508 --- /dev/null +++ b/backend/db/schema/staff_time_spans.ts @@ -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] })]); diff --git a/backend/src/index.ts b/backend/src/index.ts index 3a2ea632..8577b38a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 06521ead..76acc705 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -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, }, }, diff --git a/backend/src/modules/chat-content.ts b/backend/src/modules/chat-content.ts new file mode 100644 index 00000000..6063df1b --- /dev/null +++ b/backend/src/modules/chat-content.ts @@ -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, "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 = {} + 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) +} diff --git a/backend/src/modules/collaboration/federation.ts b/backend/src/modules/collaboration/federation.ts index 77263aae..01555b79 100644 --- a/backend/src/modules/collaboration/federation.ts +++ b/backend/src/modules/collaboration/federation.ts @@ -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" @@ -122,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 } @@ -223,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") @@ -300,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 => { @@ -326,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) @@ -336,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 } @@ -368,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) { @@ -433,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()) diff --git a/backend/src/modules/collaboration/service.ts b/backend/src/modules/collaboration/service.ts index 8a100ec8..db96fe97 100644 --- a/backend/src/modules/collaboration/service.ts +++ b/backend/src/modules/collaboration/service.ts @@ -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, "execute" | "transaction"> export const rows = async (db: Pick, query: any): Promise => (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)) }) } diff --git a/backend/src/modules/notification.service.ts b/backend/src/modules/notification.service.ts index e5f2d40e..51c1c4cd 100644 --- a/backend/src/modules/notification.service.ts +++ b/backend/src/modules/notification.service.ts @@ -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() + 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" }) } diff --git a/backend/src/modules/publiclinks.service.ts b/backend/src/modules/publiclinks.service.ts index e9b450a0..98179cef 100644 --- a/backend/src/modules/publiclinks.service.ts +++ b/backend/src/modules/publiclinks.service.ts @@ -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 + }); + } } // ================================================================= diff --git a/backend/src/modules/serialexecution.service.ts b/backend/src/modules/serialexecution.service.ts index 7a977157..9ea6e7d9 100644 --- a/backend/src/modules/serialexecution.service.ts +++ b/backend/src/modules/serialexecution.service.ts @@ -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) diff --git a/backend/src/modules/time-v2/compatibility.ts b/backend/src/modules/time-v2/compatibility.ts new file mode 100644 index 00000000..a765c4fa --- /dev/null +++ b/backend/src/modules/time-v2/compatibility.ts @@ -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.' }); + } + }); +} diff --git a/backend/src/modules/time-v2/domain.ts b/backend/src/modules/time-v2/domain.ts new file mode 100644 index 00000000..c25026c6 --- /dev/null +++ b/backend/src/modules/time-v2/domain.ts @@ -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; +} diff --git a/backend/src/modules/time-v2/migration.ts b/backend/src/modules/time-v2/migration.ts new file mode 100644 index 00000000..f525724b --- /dev/null +++ b/backend/src/modules/time-v2/migration.ts @@ -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(); + 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 }; + }); + } +} diff --git a/backend/src/modules/time-v2/service.ts b/backend/src/modules/time-v2/service.ts new file mode 100644 index 00000000..d3f86591 --- /dev/null +++ b/backend/src/modules/time-v2/service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 } }; + } +} diff --git a/backend/src/modules/time/evaluation.service.ts b/backend/src/modules/time/evaluation.service.ts index 8032d32e..ea624682 100644 --- a/backend/src/modules/time/evaluation.service.ts +++ b/backend/src/modules/time/evaluation.service.ts @@ -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); diff --git a/backend/src/routes/collaboration.ts b/backend/src/routes/collaboration.ts index 9c8f9b3f..53affec6 100644 --- a/backend/src/routes/collaboration.ts +++ b/backend/src/routes/collaboration.ts @@ -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)) } diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 202154dc..32e1b5d9 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -15,6 +15,7 @@ import { } 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 @@ -160,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)) @@ -181,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(), @@ -201,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") } @@ -307,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 } }) @@ -456,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) => { diff --git a/backend/src/routes/devices/rfid.ts b/backend/src/routes/devices/rfid.ts index d3c3d455..e8c8e48f 100644 --- a/backend/src/routes/devices/rfid.ts +++ b/backend/src/routes/devices/rfid.ts @@ -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]); diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 49e38bb9..1944ee20 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -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 diff --git a/backend/src/routes/internal/time.ts b/backend/src/routes/internal/time.ts index 4f83b52f..10d11cd7 100644 --- a/backend/src/routes/internal/time.ts +++ b/backend/src/routes/internal/time.ts @@ -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) => { diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 6b36bb06..c488c8b7 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -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) diff --git a/backend/src/routes/staff/time-v2.ts b/backend/src/routes/staff/time-v2.ts new file mode 100644 index 00000000..d35cef78 --- /dev/null +++ b/backend/src/routes/staff/time-v2.ts @@ -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); + }); +} diff --git a/backend/src/routes/staff/time.ts b/backend/src/routes/staff/time.ts index 80ef4445..9cdf915f 100644 --- a/backend/src/routes/staff/time.ts +++ b/backend/src/routes/staff/time.ts @@ -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) => { diff --git a/backend/src/routes/staff/timeconnects.ts b/backend/src/routes/staff/timeconnects.ts index db89df12..722a67a6 100644 --- a/backend/src/routes/staff/timeconnects.ts +++ b/backend/src/routes/staff/timeconnects.ts @@ -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 }>( @@ -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) { diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index 54bd8a0d..e55a01b9 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -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])) diff --git a/backend/tests/chatContent.test.ts b/backend/tests/chatContent.test.ts new file mode 100644 index 00000000..9aeb4078 --- /dev/null +++ b/backend/tests/chatContent.test.ts @@ -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('').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, ' + +