KI-AGENT: Projekträume und Direktnachrichten integrieren

This commit is contained in:
2026-05-19 08:27:39 +02:00
parent 716de8a503
commit 5b3445c2dc
3 changed files with 338 additions and 28 deletions

View File

@@ -56,6 +56,7 @@ type MatrixTenantRoomOptions = {
entityType?: string | null entityType?: string | null
entityId?: number | null entityId?: number | null
entityUuid?: string | null entityUuid?: string | null
inviteUserIds?: string[]
} }
type MatrixCachedValue<T = any> = { type MatrixCachedValue<T = any> = {
@@ -247,6 +248,7 @@ export function matrixService(server: FastifyInstance) {
entityType: options.entityType || null, entityType: options.entityType || null,
entityId: options.entityId || null, entityId: options.entityId || null,
entityUuid: options.entityUuid || null, entityUuid: options.entityUuid || null,
inviteUserIds: options.inviteUserIds || [],
} }
} }
@@ -885,6 +887,7 @@ export function matrixService(server: FastifyInstance) {
const existing = await getTenantRoomStatus(tenant.id, key, name) const existing = await getTenantRoomStatus(tenant.id, key, name)
const userAccount = await provisionCurrentUser(userId, tenant.id) const userAccount = await provisionCurrentUser(userId, tenant.id)
const invitedMatrixUserIds = await matrixUserIdsForInvitees(userId, tenant.id, normalizedOptions.inviteUserIds || [])
const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id) const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id)
if (existing.exists) { if (existing.exists) {
@@ -902,6 +905,8 @@ export function matrixService(server: FastifyInstance) {
invitedUserId: userAccount.matrixUserId, invitedUserId: userAccount.matrixUserId,
} }
await inviteUsersToRoom(existing.roomId, invitedMatrixUserIds)
matrixTenantRoomCache.set(cacheKey, { matrixTenantRoomCache.set(cacheKey, {
exists: true, exists: true,
cachedUntil: Date.now() + 30 * 60 * 1000, cachedUntil: Date.now() + 30 * 60 * 1000,
@@ -923,7 +928,7 @@ export function matrixService(server: FastifyInstance) {
preset: "private_chat", preset: "private_chat",
visibility: "private", visibility: "private",
room_alias_name: tenantRoomAliasLocalpart(tenant, key), room_alias_name: tenantRoomAliasLocalpart(tenant, key),
invite: [userAccount.matrixUserId], invite: Array.from(new Set([userAccount.matrixUserId, ...invitedMatrixUserIds])),
initial_state: [ initial_state: [
{ {
type: "m.room.history_visibility", type: "m.room.history_visibility",
@@ -994,6 +999,42 @@ export function matrixService(server: FastifyInstance) {
return value return value
} }
const matrixUserIdsForInvitees = async (
currentUserId: string,
tenantId: number,
inviteUserIds: string[]
) => {
const uniqueUserIds = Array.from(new Set(inviteUserIds.filter((id) => id && id !== currentUserId)))
return await Promise.all(uniqueUserIds.map(async (inviteUserId) => {
const account = await provisionCurrentUser(inviteUserId, tenantId)
return account.matrixUserId
}))
}
const inviteUsersToRoom = async (roomId: string | null, matrixUserIds: string[]) => {
if (!roomId || !matrixUserIds.length) return
const serviceLogin = await ensureServiceAccessToken()
for (const matrixUserId of matrixUserIds) {
try {
await requestMatrixJson(
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`,
serviceLogin.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: matrixUserId }),
}
)
} catch (err: any) {
if (err.statusCode === 403 || err.statusCode === 400) continue
throw err
}
}
}
const ensureCurrentUserJoinedRoom = async ( const ensureCurrentUserJoinedRoom = async (
userId: string, userId: string,
tenantId: number | null, tenantId: number | null,

View File

@@ -1,6 +1,7 @@
import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { and, eq, ne } from "drizzle-orm" import { and, eq, inArray, ne } from "drizzle-orm"
import { authTenantUsers, authUsers } from "../../db/schema" import { authProfiles, authTenantUsers, authUsers, projects } from "../../db/schema"
import { matrixService } from "../modules/matrix.service" import { matrixService } from "../modules/matrix.service"
import { NotificationService, UserDirectory } from "../modules/notification.service" import { NotificationService, UserDirectory } from "../modules/notification.service"
@@ -47,6 +48,22 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
} }
const projectRoomKey = (projectId: number) => `project_${projectId}`
const directRoomKey = (firstUserId: string, secondUserId: string) => {
const hash = createHash("sha256")
.update([firstUserId, secondUserId].sort().join(":"))
.digest("hex")
.slice(0, 16)
return `direct_${hash}`
}
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 callModeFromRequest = (req: any): "audio" | "video" => { const callModeFromRequest = (req: any): "audio" | "video" => {
const body = (req.body || {}) as { mode?: string } const body = (req.body || {}) as { mode?: string }
return body.mode === "audio" ? "audio" : "video" return body.mode === "audio" ? "audio" : "video"
@@ -131,6 +148,186 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.get("/communication/matrix/project-rooms", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const [roomsRes, projectRows] = await Promise.all([
matrix.listTenantRooms(req.user.tenant_id),
server.db
.select({
id: projects.id,
name: projects.name,
projectNumber: projects.projectNumber,
profiles: projects.profiles,
})
.from(projects)
.where(and(
eq(projects.tenant, req.user.tenant_id),
eq(projects.archived, false)
))
])
const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room]))
return {
rooms: projectRows.map((project) => {
const key = projectRoomKey(project.id)
const existing = roomsByKey.get(key) as any
return {
...(existing || {}),
key,
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`,
type: "project",
entityType: "project",
entityId: project.id,
exists: Boolean(existing?.exists),
projectId: project.id,
projectNumber: project.projectNumber,
}
})
}
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix project rooms failed")
}
})
server.post("/communication/matrix/project-rooms/:projectId/provision", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { projectId: string }
const projectId = Number(params.projectId)
const [project] = await server.db
.select()
.from(projects)
.where(and(
eq(projects.tenant, req.user.tenant_id),
eq(projects.id, projectId)
))
.limit(1)
if (!project) return reply.code(404).send({ error: "Projekt nicht gefunden" })
const profileIds = (project.profiles || []) as string[]
const profileRows = profileIds.length
? await server.db
.select({ userId: authProfiles.user_id })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, req.user.tenant_id),
inArray(authProfiles.id, profileIds)
))
: []
const inviteUserIds = profileRows.map((profile) => profile.userId).filter(Boolean) as string[]
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
key: projectRoomKey(project.id),
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`,
type: "project",
entityType: "project",
entityId: project.id,
inviteUserIds,
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix project room provisioning failed")
}
})
server.get("/communication/matrix/direct-rooms", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const [roomsRes, userRows] = await Promise.all([
matrix.listTenantRooms(req.user.tenant_id),
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, req.user.tenant_id)
))
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
ne(authTenantUsers.user_id, req.user.user_id)
))
])
const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room]))
return {
rooms: userRows.map((user) => {
const key = directRoomKey(req.user.user_id, user.userId)
const existing = roomsByKey.get(key) as any
const name = displayUserName(user)
return {
...(existing || {}),
key,
name,
topic: `Direktnachricht mit ${name}`,
type: "direct",
entityType: "user",
entityUuid: user.userId,
exists: Boolean(existing?.exists),
userId: user.userId,
email: user.email,
}
})
}
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix direct rooms failed")
}
})
server.post("/communication/matrix/direct-rooms/:userId/provision", async (req, reply) => {
try {
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
const params = req.params as { userId: string }
const [target] = 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, req.user.tenant_id)
))
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
eq(authTenantUsers.user_id, params.userId)
))
.limit(1)
if (!target || target.userId === req.user.user_id) {
return reply.code(404).send({ error: "Benutzer nicht gefunden" })
}
const targetName = displayUserName(target)
return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, {
key: directRoomKey(req.user.user_id, target.userId),
name: targetName,
topic: `Direktnachricht mit ${targetName}`,
type: "direct",
entityType: "user",
entityUuid: target.userId,
inviteUserIds: [target.userId],
})
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix direct room provisioning failed")
}
})
server.post("/communication/matrix/rooms", async (req, reply) => { server.post("/communication/matrix/rooms", async (req, reply) => {
try { try {
return await matrix.provisionTenantRoom( return await matrix.provisionTenantRoom(

View File

@@ -7,6 +7,8 @@ const { $api } = useNuxtApp()
const status = ref(null) const status = ref(null)
const identity = ref(null) const identity = ref(null)
const matrixRooms = ref([]) const matrixRooms = ref([])
const matrixProjectRooms = ref([])
const matrixDirectRooms = ref([])
const activeRoomKey = ref("allgemein") const activeRoomKey = ref("allgemein")
const matrixMessages = ref([]) const matrixMessages = ref([])
const matrixMembers = ref([]) const matrixMembers = ref([])
@@ -35,6 +37,7 @@ const roomCreateForm = ref({
}) })
const loading = ref(false) const loading = ref(false)
const roomProvisioning = ref(false) const roomProvisioning = ref(false)
const roomProvisioningKey = ref("")
const roomCreating = ref(false) const roomCreating = ref(false)
const roomMembersSyncing = ref(false) const roomMembersSyncing = ref(false)
const matrixMessagesLoading = ref(false) const matrixMessagesLoading = ref(false)
@@ -54,7 +57,7 @@ const canUseMatrixChat = computed(() =>
) )
const activeRoom = computed(() => const activeRoom = computed(() =>
matrixRooms.value.find((room) => room.key === activeRoomKey.value) || { rooms.value.find((room) => room.key === activeRoomKey.value) || {
key: activeRoomKey.value, key: activeRoomKey.value,
name: activeRoomKey.value, name: activeRoomKey.value,
description: "Mandantenweiter Austausch", description: "Mandantenweiter Austausch",
@@ -109,26 +112,34 @@ const roomCreateKeyPreview = computed(() =>
normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name) normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name)
) )
const rooms = computed(() => [ const rooms = computed(() => {
...matrixRooms.value.map((room) => ({ const baseRooms = matrixRooms.value
.filter((room) => !["project", "direct"].includes(room.type))
.map((room) => ({
...room,
group: "Räume",
icon: "i-heroicons-chat-bubble-left-right",
description: room.alias || room.roomId || "Mandantenweiter Austausch"
}))
const projectRooms = matrixProjectRooms.value.map((room) => ({
...room, ...room,
description: room.alias || room.roomId || "Mandantenweiter Austausch" group: "Projekte",
})), icon: "i-heroicons-briefcase",
{ description: room.projectNumber || room.topic || "Projektkommunikation",
key: "projects", provisionEndpoint: `/api/communication/matrix/project-rooms/${encodeURIComponent(room.projectId)}/provision`
name: "Projekt-Chats", }))
description: "Nächste Ausbaustufe",
exists: false, const directRooms = matrixDirectRooms.value.map((room) => ({
disabled: true ...room,
}, group: "Direkt",
{ icon: "i-heroicons-user-circle",
key: "direct", description: room.email || room.topic || "Direktnachricht",
name: "Direktnachrichten", provisionEndpoint: `/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`
description: "Nächste Ausbaustufe", }))
exists: false,
disabled: true return [...baseRooms, ...projectRooms, ...directRooms]
} })
])
const normalizeRoomKey = (value) => { const normalizeRoomKey = (value) => {
const normalized = String(value || "") const normalized = String(value || "")
@@ -149,12 +160,61 @@ const normalizeRoomKey = (value) => {
const setActiveRoom = async (room) => { const setActiveRoom = async (room) => {
if (room.disabled || room.key === activeRoomKey.value) return if (room.disabled || room.key === activeRoomKey.value) return
if (!room.exists && room.provisionEndpoint) {
await provisionRoomFromList(room)
return
}
activeRoomKey.value = room.key activeRoomKey.value = room.key
matrixMessages.value = [] matrixMessages.value = []
matrixMembers.value = [] matrixMembers.value = []
await loadRoomChat({ includeMembers: true }) await loadRoomChat({ includeMembers: true })
} }
const mergeRoomIntoLists = (room) => {
upsertRoom({ ...room, exists: true })
if (room.type === "project") {
matrixProjectRooms.value = matrixProjectRooms.value.map((item) =>
item.key === room.key ? { ...item, ...room, exists: true } : item
)
}
if (room.type === "direct") {
matrixDirectRooms.value = matrixDirectRooms.value.map((item) =>
item.key === room.key ? { ...item, ...room, exists: true } : item
)
}
}
const provisionRoomFromList = async (room) => {
roomProvisioningKey.value = room.key
try {
const createdRoom = await $api(room.provisionEndpoint, {
method: "POST"
})
mergeRoomIntoLists(createdRoom)
activeRoomKey.value = createdRoom.key
matrixMessages.value = []
matrixMembers.value = []
toast.add({
title: "Chatraum ist bereit",
color: "success"
})
await loadRoomChat({ includeMembers: true })
} catch (error) {
toast.add({
title: "Chatraum konnte nicht erstellt werden",
color: "error"
})
} finally {
roomProvisioningKey.value = ""
}
}
const upsertRoom = (room) => { const upsertRoom = (room) => {
const roomWasKnown = matrixRooms.value.some((item) => item.key === room.key) const roomWasKnown = matrixRooms.value.some((item) => item.key === room.key)
matrixRooms.value = roomWasKnown matrixRooms.value = roomWasKnown
@@ -192,15 +252,19 @@ const scrollMessagesToBottom = async () => {
const loadChatInfo = async () => { const loadChatInfo = async () => {
loading.value = true loading.value = true
try { try {
const [statusRes, identityRes, roomsRes] = await Promise.all([ const [statusRes, identityRes, roomsRes, projectRoomsRes, directRoomsRes] = await Promise.all([
$api("/api/communication/matrix/status"), $api("/api/communication/matrix/status"),
$api("/api/communication/matrix/me"), $api("/api/communication/matrix/me"),
$api("/api/communication/matrix/rooms") $api("/api/communication/matrix/rooms"),
$api("/api/communication/matrix/project-rooms"),
$api("/api/communication/matrix/direct-rooms")
]) ])
status.value = statusRes status.value = statusRes
identity.value = identityRes identity.value = identityRes
matrixRooms.value = roomsRes.rooms || [] matrixRooms.value = roomsRes.rooms || []
matrixProjectRooms.value = projectRoomsRes.rooms || []
matrixDirectRooms.value = directRoomsRes.rooms || []
lastUpdated.value = new Date() lastUpdated.value = new Date()
if (activeRoom.value?.exists && canUseMatrixChat.value) { if (activeRoom.value?.exists && canUseMatrixChat.value) {
@@ -869,14 +933,22 @@ onBeforeUnmount(() => {
@click="setActiveRoom(room)" @click="setActiveRoom(room)"
> >
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"> <span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5" /> <UIcon :name="room.icon || 'i-heroicons-chat-bubble-left-right'" class="size-5" />
</span> </span>
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ room.name }}</span> <span class="block truncate text-sm font-medium">{{ room.name }}</span>
<span class="block truncate text-xs text-muted">{{ room.description }}</span> <span class="block truncate text-xs text-muted">{{ room.description }}</span>
</span> </span>
<UBadge <UBadge
v-if="room.exists" v-if="roomProvisioningKey === room.key"
color="primary"
variant="soft"
size="xs"
>
lädt
</UBadge>
<UBadge
v-else-if="room.exists"
color="success" color="success"
variant="soft" variant="soft"
size="xs" size="xs"
@@ -978,7 +1050,7 @@ onBeforeUnmount(() => {
<div class="border-b border-default bg-default p-3 lg:hidden"> <div class="border-b border-default bg-default p-3 lg:hidden">
<div class="flex gap-2 overflow-x-auto pb-1"> <div class="flex gap-2 overflow-x-auto pb-1">
<button <button
v-for="room in rooms" v-for="room in rooms"
:key="room.key" :key="room.key"
type="button" type="button"