KI-AGENT: Matrix durch nativen FEDEO-Chat ersetzen
This commit is contained in:
@@ -8,9 +8,6 @@ CREATE TABLE "communication_rooms" (
|
||||
"entity_type" text,
|
||||
"entity_id" bigint,
|
||||
"entity_uuid" uuid,
|
||||
"matrix_room_id" text,
|
||||
"matrix_alias" text,
|
||||
"parent_space_room_id" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
|
||||
@@ -8,9 +8,6 @@ CREATE TABLE IF NOT EXISTS "communication_rooms" (
|
||||
"entity_type" text,
|
||||
"entity_id" bigint,
|
||||
"entity_uuid" uuid,
|
||||
"matrix_room_id" text,
|
||||
"matrix_alias" text,
|
||||
"parent_space_room_id" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
|
||||
42
backend/db/migrations/0070_native_communication_chat.sql
Normal file
42
backend/db/migrations/0070_native_communication_chat.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_room_id";
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_alias";
|
||||
ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "parent_space_room_id";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_room_members" (
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade,
|
||||
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "communication_room_members_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "communication_room_members_user_idx"
|
||||
ON "communication_room_members" ("user_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_messages" (
|
||||
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade,
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"author_user_id" uuid NOT NULL REFERENCES "auth_users"("id"),
|
||||
"body" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "communication_messages_room_message_idx"
|
||||
ON "communication_messages" ("room_id", "id");
|
||||
CREATE INDEX IF NOT EXISTS "communication_messages_tenant_idx"
|
||||
ON "communication_messages" ("tenant_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "communication_room_reads" (
|
||||
"room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade,
|
||||
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade,
|
||||
"last_read_message_id" bigint,
|
||||
"read_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "communication_room_reads_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id")
|
||||
);
|
||||
|
||||
INSERT INTO "communication_room_members" ("room_id", "user_id")
|
||||
SELECT room.id, tenant_user.user_id
|
||||
FROM "communication_rooms" room
|
||||
JOIN "auth_tenant_users" tenant_user ON tenant_user.tenant_id = room.tenant_id
|
||||
WHERE room.type IN ('general', 'room')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -470,6 +470,13 @@
|
||||
"when": 1788803000000,
|
||||
"tag": "0069_reset_email_entity_suggestions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "7",
|
||||
"when": 1788850800000,
|
||||
"tag": "0070_native_communication_chat",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
34
backend/db/schema/communication_messages.ts
Normal file
34
backend/db/schema/communication_messages.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
import { tenants } from "./tenants"
|
||||
|
||||
export const communicationMessages = pgTable(
|
||||
"communication_messages",
|
||||
{
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
authorUserId: uuid("author_user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id),
|
||||
body: text("body").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
roomMessageIdx: index("communication_messages_room_message_idx").on(table.roomId, table.id),
|
||||
tenantIdx: index("communication_messages_tenant_idx").on(table.tenantId),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationMessage = typeof communicationMessages.$inferSelect
|
||||
export type NewCommunicationMessage = typeof communicationMessages.$inferInsert
|
||||
26
backend/db/schema/communication_room_members.ts
Normal file
26
backend/db/schema/communication_room_members.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
|
||||
export const communicationRoomMembers = pgTable(
|
||||
"communication_room_members",
|
||||
{
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.roomId, table.userId] }),
|
||||
userIdx: index("communication_room_members_user_idx").on(table.userId),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationRoomMember = typeof communicationRoomMembers.$inferSelect
|
||||
export type NewCommunicationRoomMember = typeof communicationRoomMembers.$inferInsert
|
||||
26
backend/db/schema/communication_room_reads.ts
Normal file
26
backend/db/schema/communication_room_reads.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { bigint, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authUsers } from "./auth_users"
|
||||
import { communicationRooms } from "./communication_rooms"
|
||||
|
||||
export const communicationRoomReads = pgTable(
|
||||
"communication_room_reads",
|
||||
{
|
||||
roomId: uuid("room_id")
|
||||
.notNull()
|
||||
.references(() => communicationRooms.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
lastReadMessageId: bigint("last_read_message_id", { mode: "number" }),
|
||||
readAt: timestamp("read_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.roomId, table.userId] }),
|
||||
})
|
||||
)
|
||||
|
||||
export type CommunicationRoomRead = typeof communicationRoomReads.$inferSelect
|
||||
export type NewCommunicationRoomRead = typeof communicationRoomReads.$inferInsert
|
||||
@@ -30,10 +30,6 @@ export const communicationRooms = pgTable(
|
||||
entityId: bigint("entity_id", { mode: "number" }),
|
||||
entityUuid: uuid("entity_uuid"),
|
||||
|
||||
matrixRoomId: text("matrix_room_id"),
|
||||
matrixAlias: text("matrix_alias"),
|
||||
parentSpaceRoomId: text("parent_space_room_id"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
|
||||
@@ -16,6 +16,9 @@ export * from "./checkexecutions"
|
||||
export * from "./checks"
|
||||
export * from "./citys"
|
||||
export * from "./communication_rooms"
|
||||
export * from "./communication_room_members"
|
||||
export * from "./communication_messages"
|
||||
export * from "./communication_room_reads"
|
||||
export * from "./contacts"
|
||||
export * from "./contracts"
|
||||
export * from "./contracttypes"
|
||||
|
||||
@@ -61,7 +61,6 @@ import {loadSecrets, secrets} from "./utils/secrets";
|
||||
import {initMailer} from "./utils/mailer"
|
||||
import {initS3} from "./utils/s3";
|
||||
import { runBootstrap } from "./modules/bootstrap.service";
|
||||
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
|
||||
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
|
||||
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
|
||||
|
||||
@@ -89,7 +88,6 @@ async function main() {
|
||||
await app.register(dbPlugin);
|
||||
await app.register(servicesPlugin);
|
||||
await runBootstrap(app);
|
||||
startMatrixPushWorker(app);
|
||||
startCentralServicesHeartbeat(app);
|
||||
startDocumentImportWorker(app);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
tenants,
|
||||
texttemplates,
|
||||
} from "../../db/schema"
|
||||
import { matrixService } from "./matrix.service"
|
||||
|
||||
const adminPermissions = [
|
||||
"mcp.tokens.write",
|
||||
@@ -456,18 +455,4 @@ export async function runBootstrap(server: FastifyInstance) {
|
||||
await ensureTenantBaseData(server, tenant.id, adminUser.id)
|
||||
console.log("✅ Bootstrap-Grunddaten geprüft")
|
||||
|
||||
if (process.env.FEDEO_BOOTSTRAP_MATRIX === "true") {
|
||||
try {
|
||||
const matrix = matrixService(server)
|
||||
await matrix.provisionTenantRoom(adminUser.id, tenant.id, {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
type: "general",
|
||||
})
|
||||
console.log("✅ Bootstrap-Matrix-Kommunikation geprüft")
|
||||
} catch (err) {
|
||||
console.error("❌ Bootstrap-Matrix-Kommunikation fehlgeschlagen:", err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { and, desc, eq, inArray, isNotNull, ne } from "drizzle-orm"
|
||||
import { authProfiles, authTenantUsers, authUsers, communicationRooms, notificationsItems } from "../../db/schema"
|
||||
import { matrixService } from "./matrix.service"
|
||||
import { NotificationService, UserDirectory } from "./notification.service"
|
||||
|
||||
type ChatRecipient = {
|
||||
userId: string
|
||||
email?: string | null
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
fullName?: string | null
|
||||
matrixUserId?: string
|
||||
}
|
||||
|
||||
type MatrixPushWorkerEvent = {
|
||||
at: string
|
||||
type: string
|
||||
roomKey?: string
|
||||
roomId?: string | null
|
||||
messageId?: string
|
||||
sender?: string
|
||||
targets?: number
|
||||
created?: number
|
||||
delivered?: number
|
||||
failed?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const matrixPushWorkerState = {
|
||||
enabled: false,
|
||||
startedAt: null as string | null,
|
||||
lastRunAt: null as string | null,
|
||||
lastJoinAt: null as string | null,
|
||||
lastJoinTotal: 0,
|
||||
lastJoinJoined: 0,
|
||||
lastJoinFailed: 0,
|
||||
hasSyncToken: false,
|
||||
lastSyncRooms: 0,
|
||||
lastSyncMessages: 0,
|
||||
lastMatchedRooms: 0,
|
||||
lastNotificationsCreated: 0,
|
||||
lastNotificationsDelivered: 0,
|
||||
lastNotificationsFailed: 0,
|
||||
lastError: null as string | null,
|
||||
events: [] as MatrixPushWorkerEvent[],
|
||||
}
|
||||
|
||||
const rememberWorkerEvent = (event: MatrixPushWorkerEvent) => {
|
||||
matrixPushWorkerState.events = [
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
...event,
|
||||
},
|
||||
...matrixPushWorkerState.events,
|
||||
].slice(0, 25)
|
||||
}
|
||||
|
||||
export const getMatrixPushWorkerState = () => ({
|
||||
...matrixPushWorkerState,
|
||||
events: [...matrixPushWorkerState.events],
|
||||
})
|
||||
|
||||
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||
const rows = await server.db
|
||||
.select({ email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => {
|
||||
const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ")
|
||||
return name || user.email || "Benutzer"
|
||||
}
|
||||
|
||||
const directRoomKey = (firstUserId: string, secondUserId: string) => {
|
||||
const hash = createHash("sha256")
|
||||
.update([firstUserId, secondUserId].sort().join(":"))
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
|
||||
return `direct_${hash}`
|
||||
}
|
||||
|
||||
const mentionAliasesForUser = (user: ChatRecipient) => {
|
||||
const name = displayUserName(user)
|
||||
return Array.from(new Set([
|
||||
name,
|
||||
user.fullName,
|
||||
[user.firstName, user.lastName].filter(Boolean).join(" "),
|
||||
user.firstName,
|
||||
user.email,
|
||||
].filter(Boolean).map((value) => String(value).toLowerCase())))
|
||||
}
|
||||
|
||||
const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => {
|
||||
const normalizedText = text.toLowerCase()
|
||||
|
||||
return recipients
|
||||
.filter((recipient) => mentionAliasesForUser(recipient).some((alias) =>
|
||||
normalizedText.includes(`@${alias}`)
|
||||
))
|
||||
.map((recipient) => recipient.userId)
|
||||
}
|
||||
|
||||
export function startMatrixPushWorker(server: FastifyInstance) {
|
||||
if (process.env.MATRIX_PUSH_WORKER_DISABLED === "1") {
|
||||
server.log.info("Matrix-Push-Worker ist deaktiviert")
|
||||
return
|
||||
}
|
||||
|
||||
matrixPushWorkerState.enabled = true
|
||||
matrixPushWorkerState.startedAt = new Date().toISOString()
|
||||
rememberWorkerEvent({ at: new Date().toISOString(), type: "started" })
|
||||
|
||||
const matrix = matrixService(server)
|
||||
const notifications = new NotificationService(server, getUserDirectory)
|
||||
const intervalMs = Math.max(Number(process.env.MATRIX_PUSH_WORKER_INTERVAL_MS || 3000), 1000)
|
||||
let since: string | undefined
|
||||
let running = false
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastServiceJoinSyncAt = 0
|
||||
let errorBackoffMs = 0
|
||||
|
||||
const getTenantRecipients = async (tenantId: number) => {
|
||||
const rows = await server.db
|
||||
.select({
|
||||
userId: authTenantUsers.user_id,
|
||||
email: authUsers.email,
|
||||
firstName: authProfiles.first_name,
|
||||
lastName: authProfiles.last_name,
|
||||
fullName: authProfiles.full_name,
|
||||
})
|
||||
.from(authTenantUsers)
|
||||
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
|
||||
.leftJoin(authProfiles, and(
|
||||
eq(authProfiles.user_id, authTenantUsers.user_id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||
|
||||
return await Promise.all(rows.map(async (row) => ({
|
||||
...row,
|
||||
matrixUserId: await matrix.matrixUserIdForUser(row.userId, tenantId),
|
||||
})))
|
||||
}
|
||||
|
||||
const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => {
|
||||
const rows = await server.db
|
||||
.select({
|
||||
payload: notificationsItems.payload,
|
||||
})
|
||||
.from(notificationsItems)
|
||||
.where(and(
|
||||
eq(notificationsItems.tenantId, tenantId),
|
||||
eq(notificationsItems.userId, userId),
|
||||
eq(notificationsItems.eventType, "communication.message.new")
|
||||
))
|
||||
.orderBy(desc(notificationsItems.createdAt))
|
||||
.limit(200)
|
||||
|
||||
return rows.some((row) => (row.payload as any)?.messageId === messageId)
|
||||
}
|
||||
|
||||
const recipientsForMessage = (
|
||||
room: typeof communicationRooms.$inferSelect,
|
||||
recipients: ChatRecipient[],
|
||||
senderUserId: string | null,
|
||||
text: string
|
||||
) => {
|
||||
const candidates = senderUserId
|
||||
? recipients.filter((recipient) => recipient.userId !== senderUserId)
|
||||
: recipients
|
||||
const mentioned = new Set(mentionedRecipientIds(text, candidates))
|
||||
const directRecipients = new Set<string>()
|
||||
|
||||
if (room.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
|
||||
directRecipients.add(room.entityUuid)
|
||||
} else if (room.type === "direct" && senderUserId) {
|
||||
candidates
|
||||
.filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key)
|
||||
.forEach((recipient) => directRecipients.add(recipient.userId))
|
||||
}
|
||||
|
||||
return candidates
|
||||
.filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId))
|
||||
.map((recipient) => ({
|
||||
...recipient,
|
||||
mentioned: mentioned.has(recipient.userId),
|
||||
direct: directRecipients.has(recipient.userId),
|
||||
}))
|
||||
}
|
||||
|
||||
const deliverMessageNotification = async (
|
||||
room: typeof communicationRooms.$inferSelect,
|
||||
message: any,
|
||||
recipients: ChatRecipient[]
|
||||
) => {
|
||||
if (!message.id || message.own) return
|
||||
|
||||
const sender = recipients.find((recipient) => recipient.matrixUserId === message.sender) || null
|
||||
const text = message.body || message.attachment?.fileName || "Neue Nachricht"
|
||||
const targets = recipientsForMessage(room, recipients, sender?.userId || null, text)
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "message_seen",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: targets.length,
|
||||
})
|
||||
if (!targets.length) return
|
||||
|
||||
const senderName = sender ? displayUserName(sender) : message.senderDisplayName || message.sender || "Matrix"
|
||||
const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text
|
||||
|
||||
for (const target of targets) {
|
||||
if (await hasChatNotificationForMessage(room.tenantId, target.userId, message.id)) {
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "notification_skipped_duplicate",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: 1,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await notifications.trigger({
|
||||
tenantId: room.tenantId,
|
||||
userId: target.userId,
|
||||
eventType: "communication.message.new",
|
||||
title: target.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`,
|
||||
message: preview,
|
||||
payload: {
|
||||
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
|
||||
roomKey: room.key,
|
||||
roomName: room.name,
|
||||
roomType: room.type,
|
||||
messageId: message.id,
|
||||
matrixSender: message.sender,
|
||||
mentioned: target.mentioned,
|
||||
direct: target.direct,
|
||||
},
|
||||
channels: ["inapp", "push"],
|
||||
})
|
||||
matrixPushWorkerState.lastNotificationsCreated += result.created || 0
|
||||
matrixPushWorkerState.lastNotificationsDelivered += result.delivered || 0
|
||||
matrixPushWorkerState.lastNotificationsFailed += result.failed || 0
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "notification_triggered",
|
||||
roomKey: room.key,
|
||||
roomId: room.matrixRoomId,
|
||||
messageId: message.id,
|
||||
sender: message.sender,
|
||||
targets: 1,
|
||||
created: result.created || 0,
|
||||
delivered: result.delivered || 0,
|
||||
failed: result.failed || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const runOnce = async () => {
|
||||
if (running || stopped) return
|
||||
running = true
|
||||
|
||||
try {
|
||||
matrixPushWorkerState.lastRunAt = new Date().toISOString()
|
||||
matrixPushWorkerState.lastError = null
|
||||
matrixPushWorkerState.lastSyncRooms = 0
|
||||
matrixPushWorkerState.lastSyncMessages = 0
|
||||
matrixPushWorkerState.lastMatchedRooms = 0
|
||||
matrixPushWorkerState.lastNotificationsCreated = 0
|
||||
matrixPushWorkerState.lastNotificationsDelivered = 0
|
||||
matrixPushWorkerState.lastNotificationsFailed = 0
|
||||
|
||||
if (!lastServiceJoinSyncAt || Date.now() - lastServiceJoinSyncAt > 60_000) {
|
||||
const joinResult = await matrix.syncServiceJoinedTenantRooms()
|
||||
lastServiceJoinSyncAt = Date.now()
|
||||
matrixPushWorkerState.lastJoinAt = new Date().toISOString()
|
||||
matrixPushWorkerState.lastJoinTotal = joinResult.total
|
||||
matrixPushWorkerState.lastJoinJoined = joinResult.joined
|
||||
matrixPushWorkerState.lastJoinFailed = joinResult.failed
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "service_join_sync",
|
||||
targets: joinResult.total,
|
||||
delivered: joinResult.joined,
|
||||
failed: joinResult.failed,
|
||||
})
|
||||
if (joinResult.failed) {
|
||||
console.warn("Matrix-Push-Worker: Service-User konnte nicht alle Räume joinen", {
|
||||
total: joinResult.total,
|
||||
joined: joinResult.joined,
|
||||
failed: joinResult.failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const initial = !since
|
||||
const sync = await matrix.syncServiceRoomEvents(since, initial)
|
||||
since = sync.nextBatch || since
|
||||
matrixPushWorkerState.hasSyncToken = Boolean(since)
|
||||
matrixPushWorkerState.lastSyncRooms = sync.rooms?.length || 0
|
||||
matrixPushWorkerState.lastSyncMessages = (sync.rooms || [])
|
||||
.reduce((sum: number, room: any) => sum + (room.messages?.length || 0), 0)
|
||||
|
||||
if (!initial && sync.rooms?.length) {
|
||||
const roomIds = sync.rooms.map((room: any) => room.roomId).filter(Boolean)
|
||||
const rooms = roomIds.length
|
||||
? await server.db
|
||||
.select()
|
||||
.from(communicationRooms)
|
||||
.where(and(
|
||||
inArray(communicationRooms.matrixRoomId, roomIds),
|
||||
ne(communicationRooms.archived, true),
|
||||
isNotNull(communicationRooms.matrixRoomId)
|
||||
))
|
||||
: []
|
||||
const roomsByMatrixId = new Map(rooms.map((room) => [room.matrixRoomId, room]))
|
||||
matrixPushWorkerState.lastMatchedRooms = rooms.length
|
||||
const recipientsByTenant = new Map<number, ChatRecipient[]>()
|
||||
|
||||
for (const syncedRoom of sync.rooms) {
|
||||
const room = roomsByMatrixId.get(syncedRoom.roomId)
|
||||
if (!room || !syncedRoom.messages?.length) continue
|
||||
|
||||
if (!recipientsByTenant.has(room.tenantId)) {
|
||||
recipientsByTenant.set(room.tenantId, await getTenantRecipients(room.tenantId))
|
||||
}
|
||||
|
||||
const recipients = recipientsByTenant.get(room.tenantId) || []
|
||||
for (const message of syncedRoom.messages) {
|
||||
await deliverMessageNotification(room, message, recipients)
|
||||
}
|
||||
}
|
||||
}
|
||||
errorBackoffMs = 0
|
||||
} catch (err) {
|
||||
matrixPushWorkerState.lastError = err instanceof Error ? err.message : String(err)
|
||||
const retryAfterMs = Number((err as any)?.retryAfterMs || (err as any)?.body?.retry_after_ms || 0)
|
||||
errorBackoffMs = Math.min(
|
||||
Math.max(retryAfterMs || (errorBackoffMs ? errorBackoffMs * 2 : 30_000), 30_000),
|
||||
5 * 60_000
|
||||
)
|
||||
rememberWorkerEvent({
|
||||
at: new Date().toISOString(),
|
||||
type: "error",
|
||||
error: matrixPushWorkerState.lastError,
|
||||
})
|
||||
console.error("Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten", err)
|
||||
server.log.error({ err }, "Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten")
|
||||
} finally {
|
||||
running = false
|
||||
if (!stopped) {
|
||||
const nextDelay = errorBackoffMs || (since ? 0 : intervalMs)
|
||||
timer = setTimeout(() => void runOnce(), nextDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(() => void runOnce(), intervalMs)
|
||||
server.addHook("onClose", async () => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { matrixService } from "./matrix.service"
|
||||
|
||||
type MetricSample = {
|
||||
labels: Record<string, string>
|
||||
@@ -117,10 +116,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
|
||||
|
||||
const databaseCheck = await server.db.execute("SELECT NOW() as now")
|
||||
const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({
|
||||
reachable: false,
|
||||
error: err?.message || "Matrix-Status nicht verfügbar",
|
||||
}))
|
||||
const minioUrl = s3EndpointUrl()
|
||||
|
||||
return {
|
||||
@@ -165,7 +160,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
url: nodeExporterMetricsUrl,
|
||||
error: nodeExporterError,
|
||||
}),
|
||||
matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record<string, any>),
|
||||
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
|
||||
error: "S3_ENDPOINT ist nicht gesetzt",
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "../utils/tenantFullExport";
|
||||
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||
import { buildSystemStatus } from "../modules/system-status.service";
|
||||
import { matrixService } from "../modules/matrix.service";
|
||||
import { s3 } from "../utils/s3";
|
||||
import { secrets } from "../utils/secrets";
|
||||
|
||||
@@ -385,27 +384,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
let matrixProvisioned = false;
|
||||
let matrixProvisioningError: string | null = null;
|
||||
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
|
||||
try {
|
||||
const matrix = matrixService(server);
|
||||
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
type: "general",
|
||||
});
|
||||
matrixProvisioned = true;
|
||||
} catch (err: any) {
|
||||
matrixProvisioningError = err?.message || String(err);
|
||||
server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matrixProvisioned,
|
||||
matrixProvisioningError,
|
||||
};
|
||||
return { chatReady: true };
|
||||
};
|
||||
|
||||
const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,15 +38,6 @@ export let secrets = {
|
||||
DOKUBOX_IMAP_PASSWORD: string
|
||||
OPENAI_API_KEY: string
|
||||
STIRLING_API_KEY: string
|
||||
MATRIX_HOMESERVER_URL?: string
|
||||
MATRIX_SERVER_NAME?: string
|
||||
MATRIX_RTC_HOST?: string
|
||||
MATRIX_RTC_JWT_URL?: string
|
||||
MATRIX_LIVEKIT_URL?: string
|
||||
MATRIX_REGISTRATION_SHARED_SECRET?: string
|
||||
MATRIX_SERVICE_USER_LOCALPART?: string
|
||||
LIVEKIT_KEY?: string
|
||||
LIVEKIT_SECRET?: string
|
||||
WEB_PUSH_PUBLIC_KEY?: string
|
||||
WEB_PUSH_PRIVATE_KEY?: string
|
||||
WEB_PUSH_SUBJECT?: string
|
||||
@@ -88,15 +79,6 @@ const secretKeys = [
|
||||
"DOKUBOX_IMAP_PASSWORD",
|
||||
"OPENAI_API_KEY",
|
||||
"STIRLING_API_KEY",
|
||||
"MATRIX_HOMESERVER_URL",
|
||||
"MATRIX_SERVER_NAME",
|
||||
"MATRIX_RTC_HOST",
|
||||
"MATRIX_RTC_JWT_URL",
|
||||
"MATRIX_LIVEKIT_URL",
|
||||
"MATRIX_REGISTRATION_SHARED_SECRET",
|
||||
"MATRIX_SERVICE_USER_LOCALPART",
|
||||
"LIVEKIT_KEY",
|
||||
"LIVEKIT_SECRET",
|
||||
"WEB_PUSH_PUBLIC_KEY",
|
||||
"WEB_PUSH_PRIVATE_KEY",
|
||||
"WEB_PUSH_SUBJECT",
|
||||
|
||||
@@ -86,47 +86,6 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
|
||||
const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"])
|
||||
|
||||
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
|
||||
const matrixServerName = () =>
|
||||
process.env.MATRIX_SERVER_NAME ||
|
||||
secrets.MATRIX_SERVER_NAME ||
|
||||
process.env.DOMAIN ||
|
||||
"localhost"
|
||||
|
||||
const normalizeMatrixLocalpartSeed = (value: string) => {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ä/g, "a")
|
||||
.replace(/ö/g, "o")
|
||||
.replace(/ü/g, "u")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9._=-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^[._=-]+|[._=-]+$/g, "")
|
||||
|
||||
return normalized || "user"
|
||||
}
|
||||
|
||||
const normalizeMatrixAliasSeed = (value: string) =>
|
||||
normalizeMatrixLocalpartSeed(value)
|
||||
.replace(/[.=]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
|
||||
const tenantRoomAliasLocalpart = (
|
||||
tenant: { id: number, short?: string | null, name?: string | null },
|
||||
roomKey: string
|
||||
) => {
|
||||
const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`)
|
||||
const roomSeed = normalizeMatrixAliasSeed(roomKey)
|
||||
return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}`
|
||||
}
|
||||
|
||||
const tenantRoomAlias = (
|
||||
tenant: { id: number, short?: string | null, name?: string | null },
|
||||
roomKey: string
|
||||
) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${matrixServerName()}`
|
||||
|
||||
const tableColumns = async (client: any) => {
|
||||
const result = await client.query(`
|
||||
select table_name, column_name, data_type, is_generated
|
||||
@@ -343,6 +302,12 @@ export const buildTenantFullExport = async (
|
||||
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
|
||||
}
|
||||
|
||||
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]))
|
||||
addRows(tables, "communication_room_reads", await loadRows(client, "communication_room_reads", "room_id = any($1::uuid[])", [communicationRoomIds]))
|
||||
}
|
||||
|
||||
if (tables.entitybankaccounts?.length) {
|
||||
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
|
||||
}
|
||||
@@ -636,73 +601,6 @@ const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) =>
|
||||
}
|
||||
}
|
||||
|
||||
const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => {
|
||||
const rows = exportData.tables.communication_rooms || []
|
||||
if (!rows.length) return
|
||||
|
||||
const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [
|
||||
Number(tenant.id),
|
||||
{
|
||||
id: Number(tenant.id),
|
||||
name: tenant.name,
|
||||
short: tenant.short,
|
||||
},
|
||||
]))
|
||||
|
||||
for (const row of rows) {
|
||||
const tenantId = Number(row.tenant_id)
|
||||
const tenant = tenantById.get(tenantId)
|
||||
|
||||
row.matrix_room_id = null
|
||||
row.parent_space_room_id = null
|
||||
|
||||
if (tenant && row.key) {
|
||||
row.matrix_alias = tenantRoomAlias(tenant, String(row.key))
|
||||
} else {
|
||||
row.matrix_alias = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => {
|
||||
const rows = exportData.tables.communication_rooms || []
|
||||
if (!rows.length) return 0
|
||||
|
||||
const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [
|
||||
Number(tenant.id),
|
||||
{
|
||||
id: Number(tenant.id),
|
||||
name: tenant.name,
|
||||
short: tenant.short,
|
||||
},
|
||||
]))
|
||||
let cleaned = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const tenantId = Number(row.tenant_id)
|
||||
const key = String(row.key || "")
|
||||
const tenant = tenantById.get(tenantId)
|
||||
if (!tenantId || !key || !tenant) continue
|
||||
|
||||
const alias = tenantRoomAlias(tenant, key)
|
||||
const result = await client.query(
|
||||
`
|
||||
update communication_rooms
|
||||
set matrix_room_id = null,
|
||||
parent_space_room_id = null,
|
||||
matrix_alias = $3,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and key = $2
|
||||
`,
|
||||
[tenantId, key, alias]
|
||||
)
|
||||
|
||||
cleaned += result.rowCount || 0
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
|
||||
if (!isJsonColumn || value === null || typeof value === "undefined") return value
|
||||
if (typeof value === "string") return value
|
||||
@@ -853,7 +751,6 @@ export const importTenantFullExport = async (
|
||||
|
||||
const exportData = rawExportData
|
||||
encryptEntityBankAccountRowsForImport(exportData)
|
||||
prepareCommunicationRoomsForImport(exportData)
|
||||
const client = await pool.connect()
|
||||
const importOrder = [
|
||||
"tenants",
|
||||
@@ -955,13 +852,6 @@ export const importTenantFullExport = async (
|
||||
await reportProgress(`${table} importiert`)
|
||||
}
|
||||
|
||||
const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData)
|
||||
if (cleanedCommunicationRooms) {
|
||||
importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms })
|
||||
}
|
||||
progressDone += 1
|
||||
await reportProgress("Kommunikationsräume bereinigt")
|
||||
|
||||
await refreshSequences(client, columnsByTable)
|
||||
progressDone = progressTotal
|
||||
await reportProgress("Import abgeschlossen")
|
||||
|
||||
Reference in New Issue
Block a user