KI-AGENT: Mitgliederverwaltung und Suche im Chat umsetzen

This commit is contained in:
2026-05-20 20:21:18 +02:00
parent 4c58d175a0
commit a671ae392d
3 changed files with 472 additions and 4 deletions

View File

@@ -47,6 +47,19 @@ type MatrixJoinedMembersResponse = {
}> }>
} }
type MatrixRoomSearchResponse = {
search_categories?: {
room_events?: {
count?: number
results?: Array<{
result?: MatrixRoomEvent & {
room_id?: string
}
}>
}
}
}
type MatrixUserSession = { type MatrixUserSession = {
accessToken: string accessToken: string
matrixUserId: string matrixUserId: string
@@ -1273,6 +1286,90 @@ export function matrixService(server: FastifyInstance) {
} }
} }
const searchTenantRoomMessages = async (
userId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
query: string
) => {
const searchTerm = query.trim()
if (searchTerm.length < 2) {
return {
roomId: "",
alias: "",
key: options.key || "allgemein",
name: options.name || options.key || "Chat",
count: 0,
results: [],
}
}
const room = await provisionTenantRoom(userId, tenantId, options)
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
const [response, members] = await Promise.all([
requestMatrixJson<MatrixRoomSearchResponse>(
"/_matrix/client/v3/search",
session.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
search_categories: {
room_events: {
search_term: searchTerm,
keys: ["content.body"],
order_by: "recent",
filter: {
limit: 25,
rooms: [room.roomId],
},
},
},
}),
}
),
requestMatrixJson<MatrixJoinedMembersResponse>(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`,
session.accessToken
),
])
const roomEvents = response.search_categories?.room_events
const results = (roomEvents?.results || [])
.map((item) => item.result)
.filter((event): event is MatrixRoomEvent & { room_id?: string } =>
Boolean(
event?.event_id &&
event.type === "m.room.message" &&
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "")
)
)
.map((event) => ({
id: event.event_id,
roomId: event.room_id || room.roomId,
key: room.key,
sender: event.sender,
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
body: event.content?.body || "",
attachment: attachmentFromEvent(event),
timestamp: event.origin_server_ts,
own: event.sender === session.matrixUserId,
}))
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
count: roomEvents?.count || results.length,
results,
}
}
const sendTenantRoomMessage = async ( const sendTenantRoomMessage = async (
userId: string, userId: string,
tenantId: number | null, tenantId: number | null,
@@ -1608,13 +1705,18 @@ export function matrixService(server: FastifyInstance) {
)) ))
.where(eq(authTenantUsers.tenant_id, tenant.id)) .where(eq(authTenantUsers.tenant_id, tenant.id))
return rows const users = rows
.filter((row) => row.profileActive !== false) .filter((row) => row.profileActive !== false)
.map((row) => ({ .map((row) => ({
userId: row.userId, userId: row.userId,
email: row.email, email: row.email,
displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email, displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email,
})) }))
return await Promise.all(users.map(async (user) => ({
...user,
matrixUserId: await matrixUserIdForUser(user.userId, tenant.id),
})))
} }
const inviteMatrixUserToRoom = async ( const inviteMatrixUserToRoom = async (
@@ -1708,6 +1810,102 @@ export function matrixService(server: FastifyInstance) {
} }
} }
const inviteTenantRoomMember = async (
requestingUserId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
targetUserId: string
) => {
if (!targetUserId) {
throw Object.assign(
new Error("Benutzer ist erforderlich"),
{ statusCode: 400 }
)
}
const users = await listTenantCommunicationUsers(tenantId)
const targetUser = users.find((user) => user.userId === targetUserId)
if (!targetUser) {
throw Object.assign(
new Error("Benutzer gehört nicht zum aktiven Mandanten"),
{ statusCode: 404 }
)
}
const room = await provisionTenantRoom(requestingUserId, tenantId, options)
const account = await provisionCurrentUser(targetUser.userId, tenantId)
const inviteStatus = await inviteMatrixUserToRoom(
{ roomId: room.roomId },
account.matrixUserId,
`FEDEO-Einladung: ${room.name}`
)
await ensureCurrentUserJoinedRoom(targetUser.userId, tenantId, {
roomId: room.roomId,
alias: room.alias,
})
return {
roomId: room.roomId,
alias: room.alias,
key: room.key,
name: room.name,
userId: targetUser.userId,
email: targetUser.email,
displayName: targetUser.displayName,
matrixUserId: account.matrixUserId,
status: inviteStatus === "invited" ? "joined" : inviteStatus,
ok: true,
}
}
const removeTenantRoomMember = async (
requestingUserId: string,
tenantId: number | null,
options: MatrixTenantRoomOptions = {},
matrixUserId: string
) => {
if (!matrixUserId) {
throw Object.assign(
new Error("Matrix-Benutzer ist erforderlich"),
{ statusCode: 400 }
)
}
const room = await provisionTenantRoom(requestingUserId, tenantId, options)
const session = await createAccessTokenForUser(requestingUserId, tenantId)
if (matrixUserId === session.matrixUserId) {
throw Object.assign(
new Error("Du kannst dich nicht selbst aus dem Raum entfernen"),
{ statusCode: 400 }
)
}
const serviceLogin = await ensureServiceAccessToken()
await requestMatrixJson(
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/kick`,
serviceLogin.accessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: matrixUserId,
reason: "FEDEO-Mitgliederverwaltung",
}),
}
)
matrixJoinedRoomCache.delete(`${matrixUserId}:${room.roomId}`)
return {
success: true,
roomId: room.roomId,
alias: room.alias,
key: room.key,
matrixUserId,
}
}
const getGeneralRoomMessages = (userId: string, tenantId: number | null) => const getGeneralRoomMessages = (userId: string, tenantId: number | null) =>
getTenantRoomMessages(userId, tenantId, { getTenantRoomMessages(userId, tenantId, {
key: "allgemein", key: "allgemein",
@@ -1756,11 +1954,13 @@ export function matrixService(server: FastifyInstance) {
getTenantSpaceStatus, getTenantSpaceStatus,
provisionCurrentTenantSpace, provisionCurrentTenantSpace,
listTenantRooms, listTenantRooms,
listTenantCommunicationUsers,
getTenantRoomStatus, getTenantRoomStatus,
provisionTenantRoom, provisionTenantRoom,
createAccessTokenForUser, createAccessTokenForUser,
getTenantRoomMessages, getTenantRoomMessages,
getTenantRoomMembers, getTenantRoomMembers,
searchTenantRoomMessages,
sendTenantRoomMessage, sendTenantRoomMessage,
sendTenantRoomReaction, sendTenantRoomReaction,
markTenantRoomRead, markTenantRoomRead,
@@ -1768,6 +1968,8 @@ export function matrixService(server: FastifyInstance) {
getMediaContent, getMediaContent,
createElementRoomSession, createElementRoomSession,
createLiveKitRoomSession, createLiveKitRoomSession,
inviteTenantRoomMember,
removeTenantRoomMember,
syncTenantRoomMembers, syncTenantRoomMembers,
getGeneralRoomMessages, getGeneralRoomMessages,
getGeneralRoomMembers, getGeneralRoomMembers,

View File

@@ -603,6 +603,15 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.get("/communication/matrix/users", async (req, reply) => {
try {
const users = await matrix.listTenantCommunicationUsers(req.user.tenant_id)
return { users }
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix users failed")
}
})
server.post("/communication/matrix/rooms/general/session", async (req, reply) => { server.post("/communication/matrix/rooms/general/session", async (req, reply) => {
try { try {
return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, { return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, {
@@ -713,6 +722,20 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.get("/communication/matrix/rooms/:roomKey/search", async (req, reply) => {
try {
const query = req.query as { q?: string }
return await matrix.searchTenantRoomMessages(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
query.q || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix search failed")
}
})
server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => { server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
try { try {
return await matrix.getTenantRoomMembers( return await matrix.getTenantRoomMembers(
@@ -725,6 +748,34 @@ export default async function communicationRoutes(server: FastifyInstance) {
} }
}) })
server.post("/communication/matrix/rooms/:roomKey/members/invite", async (req, reply) => {
try {
const body = req.body as { userId?: string }
return await matrix.inviteTenantRoomMember(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
body.userId || ""
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member invite failed")
}
})
server.delete("/communication/matrix/rooms/:roomKey/members/:matrixUserId", async (req, reply) => {
try {
const params = req.params as { matrixUserId: string }
return await matrix.removeTenantRoomMember(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req),
params.matrixUserId
)
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix member remove failed")
}
})
server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => { server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => {
try { try {
return await matrix.createElementRoomSession( return await matrix.createElementRoomSession(

View File

@@ -14,12 +14,17 @@ const unreadRooms = ref({})
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein") const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
const matrixMessages = ref([]) const matrixMessages = ref([])
const matrixMembers = ref([]) const matrixMembers = ref([])
const matrixTenantUsers = ref([])
const matrixMessageDraft = ref("") const matrixMessageDraft = ref("")
const matrixMessagesViewport = ref(null) const matrixMessagesViewport = ref(null)
const matrixAttachmentInput = ref(null) const matrixAttachmentInput = ref(null)
const matrixAttachmentObjectUrls = ref({}) const matrixAttachmentObjectUrls = ref({})
const matrixReplyTarget = ref(null) const matrixReplyTarget = ref(null)
const matrixDragActive = ref(false) const matrixDragActive = ref(false)
const memberInviteOpen = ref(false)
const memberInviteUserId = ref("")
const matrixSearchQuery = ref("")
const matrixSearchResults = ref([])
const roomCreateOpen = ref(false) const roomCreateOpen = ref(false)
const collapsedRoomGroups = ref({}) const collapsedRoomGroups = ref({})
const matrixCallOpen = ref(false) const matrixCallOpen = ref(false)
@@ -49,6 +54,9 @@ const roomCreating = ref(false)
const roomMembersSyncing = ref(false) const roomMembersSyncing = ref(false)
const matrixMessagesLoading = ref(false) const matrixMessagesLoading = ref(false)
const matrixMembersLoading = ref(false) const matrixMembersLoading = ref(false)
const memberInviting = ref(false)
const memberRemovingId = ref("")
const matrixSearchLoading = ref(false)
const matrixMessageSending = ref(false) const matrixMessageSending = ref(false)
const matrixAttachmentUploading = ref(false) const matrixAttachmentUploading = ref(false)
const matrixAttachmentUploadCount = ref(0) const matrixAttachmentUploadCount = ref(0)
@@ -93,6 +101,20 @@ const canSendChatInput = computed(() =>
Boolean(canUseMatrixChat.value && activeRoom.value?.exists && !matrixMessageSending.value && !matrixAttachmentUploading.value) Boolean(canUseMatrixChat.value && activeRoom.value?.exists && !matrixMessageSending.value && !matrixAttachmentUploading.value)
) )
const availableRoomInviteUsers = computed(() => {
const roomMemberIds = new Set(matrixMembers.value.map((member) => member.matrixUserId))
return matrixTenantUsers.value
.filter((user) => !roomMemberIds.has(user.matrixUserId))
.sort((first, second) =>
String(first.displayName || first.email || "").localeCompare(
String(second.displayName || second.email || ""),
"de",
{ sensitivity: "base" }
)
)
})
const matrixCallTitle = computed(() => const matrixCallTitle = computed(() =>
matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz" matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz"
) )
@@ -223,6 +245,8 @@ const setActiveRoom = async (room) => {
activeRoomKey.value = room.key activeRoomKey.value = room.key
matrixMessages.value = [] matrixMessages.value = []
matrixMembers.value = [] matrixMembers.value = []
matrixSearchResults.value = []
memberInviteOpen.value = false
await loadRoomChat({ includeMembers: true }) await loadRoomChat({ includeMembers: true })
} }
@@ -253,6 +277,8 @@ const provisionRoomFromList = async (room) => {
activeRoomKey.value = createdRoom.key activeRoomKey.value = createdRoom.key
matrixMessages.value = [] matrixMessages.value = []
matrixMembers.value = [] matrixMembers.value = []
matrixSearchResults.value = []
memberInviteOpen.value = false
toast.add({ toast.add({
title: "Chatraum ist bereit", title: "Chatraum ist bereit",
@@ -359,12 +385,13 @@ const markActiveRoomRead = async () => {
const loadChatInfo = async () => { const loadChatInfo = async () => {
loading.value = true loading.value = true
try { try {
const [statusRes, identityRes, roomsRes, projectRoomsRes, directRoomsRes] = await Promise.all([ const [statusRes, identityRes, roomsRes, projectRoomsRes, directRoomsRes, usersRes] = 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/project-rooms"),
$api("/api/communication/matrix/direct-rooms") $api("/api/communication/matrix/direct-rooms"),
$api("/api/communication/matrix/users")
]) ])
status.value = statusRes status.value = statusRes
@@ -372,6 +399,7 @@ const loadChatInfo = async () => {
matrixRooms.value = roomsRes.rooms || [] matrixRooms.value = roomsRes.rooms || []
matrixProjectRooms.value = projectRoomsRes.rooms || [] matrixProjectRooms.value = projectRoomsRes.rooms || []
matrixDirectRooms.value = directRoomsRes.rooms || [] matrixDirectRooms.value = directRoomsRes.rooms || []
matrixTenantUsers.value = usersRes.users || []
lastUpdated.value = new Date() lastUpdated.value = new Date()
await loadUnreadCounts() await loadUnreadCounts()
@@ -442,6 +470,8 @@ const createRoom = async () => {
activeRoomKey.value = room.key activeRoomKey.value = room.key
matrixMessages.value = [] matrixMessages.value = []
matrixMembers.value = [] matrixMembers.value = []
matrixSearchResults.value = []
memberInviteOpen.value = false
roomCreateForm.value = { roomCreateForm.value = {
name: "", name: "",
key: "", key: "",
@@ -545,6 +575,89 @@ const syncRoomMembers = async () => {
} }
} }
const toggleMemberInvite = () => {
memberInviteOpen.value = !memberInviteOpen.value
if (memberInviteOpen.value && !memberInviteUserId.value && availableRoomInviteUsers.value[0]) {
memberInviteUserId.value = availableRoomInviteUsers.value[0].userId
}
}
const inviteRoomMember = async () => {
if (!memberInviteUserId.value || !canUseMatrixChat.value || !activeRoom.value?.exists) return
memberInviting.value = true
try {
const result = await $api(`${activeRoomEndpoint.value}/members/invite`, {
method: "POST",
body: {
userId: memberInviteUserId.value
}
})
toast.add({
title: "Teilnehmer eingeladen",
description: result.displayName || result.email || result.matrixUserId,
color: "success"
})
memberInviteUserId.value = ""
memberInviteOpen.value = false
await loadRoomMembers()
} catch (error) {
toast.add({
title: "Teilnehmer konnte nicht eingeladen werden",
color: "error"
})
} finally {
memberInviting.value = false
}
}
const removeRoomMember = async (member) => {
if (!member?.matrixUserId || member.own || !canUseMatrixChat.value || !activeRoom.value?.exists) return
memberRemovingId.value = member.matrixUserId
try {
await $api(`${activeRoomEndpoint.value}/members/${encodeURIComponent(member.matrixUserId)}`, {
method: "DELETE"
})
matrixMembers.value = matrixMembers.value.filter((item) => item.matrixUserId !== member.matrixUserId)
toast.add({
title: "Teilnehmer entfernt",
color: "success"
})
} catch (error) {
toast.add({
title: "Teilnehmer konnte nicht entfernt werden",
color: "error"
})
} finally {
memberRemovingId.value = ""
}
}
const searchRoomMessages = async () => {
const query = matrixSearchQuery.value.trim()
if (query.length < 2 || !canUseMatrixChat.value || !activeRoom.value?.exists) {
matrixSearchResults.value = []
return
}
matrixSearchLoading.value = true
try {
const res = await $api(`${activeRoomEndpoint.value}/search?q=${encodeURIComponent(query)}`)
matrixSearchResults.value = res.results || []
} catch (error) {
toast.add({
title: "Suche konnte nicht ausgeführt werden",
color: "error"
})
} finally {
matrixSearchLoading.value = false
}
}
const openMatrixCall = async (mode = "video") => { const openMatrixCall = async (mode = "video") => {
if (matrixLiveKitRoom && matrixCallConnected.value) { if (matrixLiveKitRoom && matrixCallConnected.value) {
matrixCallOpen.value = true matrixCallOpen.value = true
@@ -1708,6 +1821,59 @@ onBeforeUnmount(() => {
</div> </div>
<div class="space-y-5 p-4"> <div class="space-y-5 p-4">
<div>
<h4 class="mb-2 text-xs font-medium uppercase text-muted">
Suche
</h4>
<form
class="flex gap-2"
@submit.prevent="searchRoomMessages"
>
<UInput
v-model="matrixSearchQuery"
size="sm"
class="min-w-0 flex-1"
placeholder="Nachrichten suchen"
:disabled="!canUseMatrixChat || !activeRoom?.exists"
/>
<UButton
type="submit"
icon="i-heroicons-magnifying-glass"
size="sm"
color="neutral"
variant="outline"
:loading="matrixSearchLoading"
:disabled="matrixSearchQuery.trim().length < 2 || !canUseMatrixChat || !activeRoom?.exists"
/>
</form>
<div
v-if="matrixSearchResults.length"
class="mt-3 max-h-52 space-y-2 overflow-y-auto"
>
<button
v-for="result in matrixSearchResults"
:key="result.id"
type="button"
class="block w-full rounded-md border border-default px-3 py-2 text-left hover:bg-muted"
@click="setReplyTarget(result)"
>
<div class="mb-1 flex items-center justify-between gap-2 text-xs text-muted">
<span class="truncate">{{ result.senderDisplayName || result.sender }}</span>
<span class="shrink-0">{{ formatMessageTime(result.timestamp) }}</span>
</div>
<p class="line-clamp-2 text-sm text-highlighted">
{{ result.body || result.attachment?.fileName || "Anhang" }}
</p>
</button>
</div>
<p
v-else-if="matrixSearchQuery.trim().length >= 2 && !matrixSearchLoading"
class="mt-3 text-sm text-muted"
>
Keine Treffer in diesem Raum.
</p>
</div>
<div> <div>
<div class="mb-2 flex items-center justify-between gap-2"> <div class="mb-2 flex items-center justify-between gap-2">
<h4 class="text-xs font-medium uppercase text-muted"> <h4 class="text-xs font-medium uppercase text-muted">
@@ -1715,7 +1881,7 @@ onBeforeUnmount(() => {
</h4> </h4>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<UButton <UButton
icon="i-heroicons-user-plus" icon="i-heroicons-users"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
size="xs" size="xs"
@@ -1723,6 +1889,14 @@ onBeforeUnmount(() => {
:disabled="!canUseMatrixChat || !activeRoom?.exists" :disabled="!canUseMatrixChat || !activeRoom?.exists"
@click="syncRoomMembers" @click="syncRoomMembers"
/> />
<UButton
icon="i-heroicons-plus"
color="neutral"
variant="ghost"
size="xs"
:disabled="!canUseMatrixChat || !activeRoom?.exists || !availableRoomInviteUsers.length"
@click="toggleMemberInvite"
/>
<UButton <UButton
icon="i-heroicons-arrow-path" icon="i-heroicons-arrow-path"
color="neutral" color="neutral"
@@ -1735,6 +1909,36 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<form
v-if="memberInviteOpen"
class="mb-3 rounded-md border border-default p-2"
@submit.prevent="inviteRoomMember"
>
<select
v-model="memberInviteUserId"
class="mb-2 w-full rounded-md border border-default bg-default px-2 py-1.5 text-sm text-highlighted"
:disabled="memberInviting"
>
<option
v-for="user in availableRoomInviteUsers"
:key="user.userId"
:value="user.userId"
>
{{ user.displayName || user.email }}
</option>
</select>
<UButton
type="submit"
icon="i-heroicons-user-plus"
size="xs"
block
:loading="memberInviting"
:disabled="!memberInviteUserId || memberInviting"
>
Einladen
</UButton>
</form>
<div class="space-y-2"> <div class="space-y-2">
<div <div
v-for="member in matrixMembers" v-for="member in matrixMembers"
@@ -1752,6 +1956,17 @@ onBeforeUnmount(() => {
{{ member.matrixUserId }} {{ member.matrixUserId }}
</p> </p>
</div> </div>
<UButton
v-if="!member.own"
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="xs"
class="ml-auto"
:loading="memberRemovingId === member.matrixUserId"
:disabled="Boolean(memberRemovingId)"
@click="removeRoomMember(member)"
/>
</div> </div>
<p <p