291 lines
9.7 KiB
TypeScript
291 lines
9.7 KiB
TypeScript
import { CHAT_PERMISSIONS } from "@hivegrid/chat";
|
|
import {
|
|
forceArchiveChatAuditChannel,
|
|
forceRemoveChatAuditMember,
|
|
forceSetChatAuditMemberRole,
|
|
getChatAuditAdministration,
|
|
getChatAuditAttachmentDownload,
|
|
getChatAuditChannelDetail,
|
|
listChatAuditChannels,
|
|
recordChatAuditSessionStarted,
|
|
type ChatAuditAttachmentDownload,
|
|
type ChatAuditAttachmentVariant,
|
|
} from "@hivegrid/chat/audit.server";
|
|
import {
|
|
buildChatAuditAdministrationVM,
|
|
buildChatAuditOverviewVM,
|
|
buildChatChannelDetailVM,
|
|
type ChatAuditAdministrationVM,
|
|
type ChatAuditOverviewVM,
|
|
type ChatChannelDetailVM,
|
|
type ChatResolvedAuthorVM,
|
|
} from "@hivegrid/view-models";
|
|
|
|
import { requireChatPermission, type ChatRouteContext } from "../routes/chatAccess.server";
|
|
import { chatAuditAttachmentPath, chatAuditPath } from "../routes/chatRoutePaths";
|
|
import { buildChatResolvedAuthors, buildMemberChatChannelDetailVM } from "./chatViewModel.server";
|
|
import type { ChatAuditSession } from "./chatAuditSession.server";
|
|
|
|
export type ChatAuditLabels = Readonly<{
|
|
directMessage: string;
|
|
unnamedGroup: string;
|
|
unnamedProject: string;
|
|
unknownUser: string;
|
|
unknownMention: string;
|
|
}>;
|
|
|
|
export async function loadChatAuditOverview(input: {
|
|
readonly context: ChatRouteContext;
|
|
readonly tenantSlug: string;
|
|
readonly session: ChatAuditSession | null;
|
|
readonly requestId?: string;
|
|
readonly labels: Pick<ChatAuditLabels, "directMessage" | "unnamedGroup" | "unnamedProject">;
|
|
}): Promise<ChatAuditOverviewVM> {
|
|
const context = requireChatPermission(input.context, CHAT_PERMISSIONS.readAllChannels);
|
|
const channels = input.session
|
|
? await listChatAuditChannels({
|
|
...repositoryContext(context),
|
|
requestId: input.requestId ?? crypto.randomUUID(),
|
|
auditSession: auditSessionContext(input.session),
|
|
})
|
|
: [];
|
|
return buildChatAuditOverviewVM({
|
|
canAdminister: context.permissions.includes(CHAT_PERMISSIONS.admin),
|
|
session: input.session
|
|
? {
|
|
id: input.session.id,
|
|
reason: input.session.reason,
|
|
expiresAtIso: new Date(input.session.expiresAt).toISOString(),
|
|
}
|
|
: null,
|
|
channels: channels.map((channel) => ({
|
|
id: channel.id,
|
|
kind: channel.kind,
|
|
title: resolveAuditTitle(channel, input.labels),
|
|
description: channel.description,
|
|
participantLabels: channel.participants.map((participant) => participant.displayName),
|
|
memberCount: channel.memberCount,
|
|
isPrivate: channel.isPrivate,
|
|
archivedAtIso: channel.archivedAtIso,
|
|
lastMessageAtIso: channel.lastMessageAtIso,
|
|
detailHref: `${chatAuditPath(input.tenantSlug)}/${encodeURIComponent(channel.id)}`,
|
|
})),
|
|
});
|
|
}
|
|
|
|
export async function startChatAuditSession(input: {
|
|
readonly context: ChatRouteContext;
|
|
readonly session: ChatAuditSession;
|
|
readonly requestId: string;
|
|
}): Promise<void> {
|
|
const context = requireChatPermission(input.context, CHAT_PERMISSIONS.readAllChannels);
|
|
await recordChatAuditSessionStarted({
|
|
...repositoryContext(context),
|
|
requestId: input.requestId,
|
|
auditSession: auditSessionContext(input.session),
|
|
});
|
|
}
|
|
|
|
export async function loadChatAuditChannel(input: {
|
|
readonly context: ChatRouteContext;
|
|
readonly tenantSlug: string;
|
|
readonly chatId: string;
|
|
readonly session: ChatAuditSession;
|
|
readonly requestId: string;
|
|
readonly beforeMessageCursor?: string;
|
|
readonly labels: ChatAuditLabels;
|
|
}): Promise<{
|
|
readonly vm: ChatChannelDetailVM;
|
|
readonly administrationVm: ChatAuditAdministrationVM;
|
|
readonly authors: readonly ChatResolvedAuthorVM[];
|
|
}> {
|
|
const context = requireChatPermission(input.context, CHAT_PERMISSIONS.readAllChannels);
|
|
const [detail, administration] = await Promise.all([
|
|
getChatAuditChannelDetail({
|
|
...repositoryContext(context),
|
|
chatId: input.chatId,
|
|
requestId: input.requestId,
|
|
auditSession: auditSessionContext(input.session),
|
|
...(input.beforeMessageCursor ? { beforeMessageCursor: input.beforeMessageCursor } : {}),
|
|
}),
|
|
getChatAuditAdministration({ ...repositoryContext(context), chatId: input.chatId }),
|
|
]);
|
|
const baseVm = buildMemberChatChannelDetailVM({
|
|
detail,
|
|
tenantSlug: input.tenantSlug,
|
|
currentUserId: context.userId,
|
|
permissions: [],
|
|
nowIso: new Date().toISOString(),
|
|
labels: input.labels,
|
|
});
|
|
const title = resolveAuditTitle(detail.channel, input.labels);
|
|
const administrationVm = buildChatAuditAdministrationVM({
|
|
channel: {
|
|
id: administration.channel.id,
|
|
kind: administration.channel.kind,
|
|
title,
|
|
archivedAtIso: administration.channel.archivedAtIso,
|
|
},
|
|
canAdminister: administration.canAdminister,
|
|
members: administration.members,
|
|
});
|
|
if (baseVm.status !== "ready") return { vm: baseVm, administrationVm, authors: [] };
|
|
const auditedMessages = baseVm.data.messages.map((message) => ({
|
|
...message,
|
|
attachments: message.attachments.map((attachment) => ({
|
|
...attachment,
|
|
downloadHref: chatAuditAttachmentPath(input.tenantSlug, input.chatId, attachment.fileId, "original"),
|
|
previewHref: attachment.previewHref ? chatAuditAttachmentPath(input.tenantSlug, input.chatId, attachment.fileId, "preview") : null,
|
|
})),
|
|
}));
|
|
const vm = buildChatChannelDetailVM({
|
|
header: {
|
|
...baseVm.data.header,
|
|
title,
|
|
avatar: { imageUrl: null, fallbackLabel: initials(title) },
|
|
isAuditMode: true,
|
|
canManage: false,
|
|
settingsHref: null,
|
|
searchHref: chatAuditPath(input.tenantSlug),
|
|
},
|
|
messages: auditedMessages,
|
|
pinnedMessages: baseVm.data.pinnedMessages,
|
|
typingLabels: [],
|
|
history: baseVm.data.history,
|
|
composer: {
|
|
...baseVm.data.composer,
|
|
canSend: false,
|
|
canAttachFiles: false,
|
|
canRecordVoice: false,
|
|
canMention: false,
|
|
canMarkAsTask: false,
|
|
disabledReasonKey: "chat.composer.auditReadOnly",
|
|
},
|
|
connection: { ...baseVm.data.connection, state: "blocked", reconnectAttempt: 0, retryAtIso: null },
|
|
});
|
|
return {
|
|
vm,
|
|
administrationVm,
|
|
authors: buildChatResolvedAuthors(detail.channel.participants, context.userId),
|
|
};
|
|
}
|
|
|
|
export async function loadChatAuditAttachment(input: {
|
|
readonly context: ChatRouteContext;
|
|
readonly chatId: string;
|
|
readonly fileId: string;
|
|
readonly variant: ChatAuditAttachmentVariant;
|
|
readonly session: ChatAuditSession;
|
|
readonly requestId: string;
|
|
}): Promise<ChatAuditAttachmentDownload> {
|
|
const context = requireChatPermission(input.context, CHAT_PERMISSIONS.readAllChannels);
|
|
return getChatAuditAttachmentDownload({
|
|
...repositoryContext(context),
|
|
chatId: input.chatId,
|
|
fileId: input.fileId,
|
|
variant: input.variant,
|
|
requestId: input.requestId,
|
|
auditSession: auditSessionContext(input.session),
|
|
});
|
|
}
|
|
|
|
export async function archiveChatAuditChannel(input: {
|
|
readonly context: ChatRouteContext;
|
|
readonly chatId: string;
|
|
readonly session: ChatAuditSession;
|
|
readonly requestId: string;
|
|
}): Promise<void> {
|
|
const context = requireChatAuditAdministrator(input.context);
|
|
await forceArchiveChatAuditChannel({
|
|
...repositoryContext(context),
|
|
chatId: input.chatId,
|
|
requestId: input.requestId,
|
|
auditSession: auditSessionContext(input.session),
|
|
});
|
|
}
|
|
|
|
export async function setChatAuditMemberRole(input: {
|
|
readonly context: ChatRouteContext;
|
|
readonly chatId: string;
|
|
readonly memberUserId: string;
|
|
readonly roleInChat: "admin" | "member";
|
|
readonly session: ChatAuditSession;
|
|
readonly requestId: string;
|
|
}): Promise<void> {
|
|
const context = requireChatAuditAdministrator(input.context);
|
|
await forceSetChatAuditMemberRole({
|
|
...repositoryContext(context),
|
|
chatId: input.chatId,
|
|
memberUserId: input.memberUserId,
|
|
roleInChat: input.roleInChat,
|
|
requestId: input.requestId,
|
|
auditSession: auditSessionContext(input.session),
|
|
});
|
|
}
|
|
|
|
export async function removeChatAuditMember(input: {
|
|
readonly context: ChatRouteContext;
|
|
readonly chatId: string;
|
|
readonly memberUserId: string;
|
|
readonly session: ChatAuditSession;
|
|
readonly requestId: string;
|
|
}): Promise<void> {
|
|
const context = requireChatAuditAdministrator(input.context);
|
|
await forceRemoveChatAuditMember({
|
|
...repositoryContext(context),
|
|
chatId: input.chatId,
|
|
memberUserId: input.memberUserId,
|
|
requestId: input.requestId,
|
|
auditSession: auditSessionContext(input.session),
|
|
});
|
|
}
|
|
|
|
function requireChatAuditAdministrator(context: ChatRouteContext): ChatRouteContext {
|
|
return requireChatPermission(requireChatPermission(context, CHAT_PERMISSIONS.readAllChannels), CHAT_PERMISSIONS.admin);
|
|
}
|
|
|
|
function repositoryContext(context: ChatRouteContext) {
|
|
return { tenantId: context.tenantId, userId: context.userId, permissions: context.permissions };
|
|
}
|
|
|
|
function auditSessionContext(session: ChatAuditSession) {
|
|
return {
|
|
sessionId: session.id,
|
|
reason: session.reason,
|
|
expiresAtIso: new Date(session.expiresAt).toISOString(),
|
|
};
|
|
}
|
|
|
|
function resolveAuditTitle(
|
|
channel: {
|
|
readonly kind: "project" | "group" | "dm";
|
|
readonly name: string | null;
|
|
readonly participants: readonly { readonly displayName: string }[];
|
|
},
|
|
labels: Pick<ChatAuditLabels, "directMessage" | "unnamedGroup" | "unnamedProject">
|
|
): string {
|
|
const name = channel.name?.trim();
|
|
if (name) return name;
|
|
if (channel.kind === "dm") {
|
|
return (
|
|
channel.participants
|
|
.map((participant) => participant.displayName.trim())
|
|
.filter(Boolean)
|
|
.join(" · ") || labels.directMessage
|
|
);
|
|
}
|
|
return channel.kind === "project" ? labels.unnamedProject : labels.unnamedGroup;
|
|
}
|
|
|
|
function initials(label: string): string {
|
|
return (
|
|
label
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toLocaleUpperCase() ?? "")
|
|
.join("") || "?"
|
|
);
|
|
}
|