From ad98533fbcd3a18ad519845f9138dfa4d308baea Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 2 Sep 2026 20:49:47 +0200 Subject: [PATCH] feat(telegram): add moderation audit dashboard --- src/components/dashboard-navigation.ts | 2 + src/features/dashboard/overview-page.tsx | 9 +- src/features/telegram/moderation-page.tsx | 215 ++++++++++++++++++ src/features/telegram/moderation.constants.ts | 48 ++++ src/features/telegram/moderation.functions.ts | 24 ++ src/features/telegram/user-detail/profile.tsx | 54 ++++- src/features/telegram/user-detail/types.ts | 58 ++++- src/features/telegram/users.functions.ts | 5 +- src/routeTree.gen.ts | 22 ++ src/routes/dashboard/telegram/moderation.tsx | 15 ++ 10 files changed, 438 insertions(+), 14 deletions(-) create mode 100644 src/features/telegram/moderation-page.tsx create mode 100644 src/features/telegram/moderation.constants.ts create mode 100644 src/features/telegram/moderation.functions.ts create mode 100644 src/routes/dashboard/telegram/moderation.tsx diff --git a/src/components/dashboard-navigation.ts b/src/components/dashboard-navigation.ts index 450e157..92f7251 100644 --- a/src/components/dashboard-navigation.ts +++ b/src/components/dashboard-navigation.ts @@ -5,6 +5,7 @@ import { FolderKanban, FolderTree, Globe, + History, LayoutDashboard, type LucideIcon, Settings, @@ -45,6 +46,7 @@ export const dashboardNavigation = [ items: [ { title: "Users", url: "/dashboard/telegram/users", icon: UsersRound }, { title: "Groups", url: "/dashboard/telegram/groups", icon: Database }, + { title: "Moderation", url: "/dashboard/telegram/moderation", icon: History }, { title: "Grants", url: "/dashboard/telegram/grants", icon: ShieldCheck }, ], }, diff --git a/src/features/dashboard/overview-page.tsx b/src/features/dashboard/overview-page.tsx index c59fef5..04572e5 100644 --- a/src/features/dashboard/overview-page.tsx +++ b/src/features/dashboard/overview-page.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router" -import { ArrowRight, BookOpen, Database, ShieldCheck, UsersRound } from "lucide-react" +import { ArrowRight, BookOpen, Database, History, ShieldCheck, UsersRound } from "lucide-react" import { PageHeader } from "@/components/page-header" import { Card, CardContent } from "@/components/ui/card" @@ -19,6 +19,13 @@ const areas = [ icon: Database, service: "Telegram", }, + { + title: "Moderation log", + description: "Review moderation actions, deleted-message counts, and BanAll progress.", + to: "/dashboard/telegram/moderation", + icon: History, + service: "Telegram", + }, { title: "Grants", description: "Review active and scheduled Telegram grants authorized by the association board.", diff --git a/src/features/telegram/moderation-page.tsx b/src/features/telegram/moderation-page.tsx new file mode 100644 index 0000000..9f3bb96 --- /dev/null +++ b/src/features/telegram/moderation-page.tsx @@ -0,0 +1,215 @@ +import { Link } from "@tanstack/react-router" +import { History } from "lucide-react" +import { useMemo } from "react" + +import { DataToolbar } from "@/components/data-toolbar" +import { EmptyState } from "@/components/empty-state" +import { Pagination } from "@/components/pagination" +import { Badge } from "@/components/ui/badge" +import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table" +import { createAppColumnHelper, useAppTable } from "@/lib/table" + +import { + MODERATION_ACTION_LABELS, + MODERATION_STATUS_LABELS, + moderationStatusBadgeVariant, + moderationUserName, + shouldShowDeletedMessageCount, +} from "./moderation.constants" +import type { ModerationAudit } from "./user-detail/types" + +function formatDate(value: Date | string) { + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) +} + +const auditColumnHelper = createAppColumnHelper() + +export function TelegramModerationPage({ audits }: { audits: ModerationAudit[] }) { + const columns = useMemo( + () => + auditColumnHelper.columns([ + auditColumnHelper.accessor("type", { + header: "Action", + cell: ({ row }) => ( +
+ {MODERATION_ACTION_LABELS[row.original.type]} + #{row.original.id} +
+ ), + }), + auditColumnHelper.accessor("targetId", { + header: "Target", + cell: ({ row }) => ( + + {moderationUserName(row.original.target, row.original.targetId)} + {row.original.targetId} + + ), + }), + auditColumnHelper.accessor("adminId", { + header: "Moderator", + cell: ({ row }) => ( +
+ {moderationUserName(row.original.admin, row.original.adminId)} + {row.original.adminId} +
+ ), + }), + auditColumnHelper.display({ + id: "scope", + header: "Scope", + cell: ({ row }) => ( +
+ + {row.original.groupTitle ?? (row.original.groupId ? `Chat ${row.original.groupId}` : "All groups")} + + {row.original.totalGroupCount > 0 && ( + + {row.original.successGroupCount} succeeded, {row.original.failedGroupCount} failed of{" "} + {row.original.totalGroupCount} + + )} +
+ ), + }), + auditColumnHelper.accessor("status", { + header: "Result", + cell: ({ row }) => ( +
+ + {MODERATION_STATUS_LABELS[row.original.status]} + + {shouldShowDeletedMessageCount(row.original) && ( + + {row.original.deletedMessageCount === null ? ( + "Recent message count unavailable" + ) : ( + <> + {row.original.deletedMessageCount} recent message + {row.original.deletedMessageCount === 1 ? "" : "s"} deleted + + )} + + )} +
+ ), + }), + auditColumnHelper.accessor("reason", { + header: "Reason", + cell: ({ getValue }) => ( +

+ {getValue() ?? "No reason provided"} +

+ ), + }), + auditColumnHelper.accessor("createdAt", { + header: "Created", + cell: ({ getValue }) => ( + + ), + }), + ]), + [] + ) + + const table = useAppTable({ + key: "telegram-moderation", + columns, + data: audits, + initialState: { pagination: { pageIndex: 0, pageSize: 25 } }, + globalFilterFn: (row, _columnId, value) => { + const audit = row.original + const query = String(value ?? "") + .trim() + .toLocaleLowerCase() + const searchable = [ + MODERATION_ACTION_LABELS[audit.type], + MODERATION_STATUS_LABELS[audit.status], + audit.reason, + audit.groupTitle, + audit.groupId, + audit.targetId, + audit.adminId, + moderationUserName(audit.target, audit.targetId), + moderationUserName(audit.admin, audit.adminId), + audit.target?.username, + audit.admin?.username, + ] + .filter((entry) => entry !== null && entry !== undefined) + .join(" ") + .toLocaleLowerCase() + return !query || searchable.includes(query.replace(/^@/, "")) + }, + }) + const filteredCount = table.getFilteredRowModel().rows.length + const hasSearch = Boolean(String(table.state.globalFilter ?? "").trim()) + + return ( +
+ { + table.setGlobalFilter(value) + table.setPageIndex(0) + }} + /> + {filteredCount ? ( + <> + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : } + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + + + ))} + + ))} + +
+
+ table.setPageIndex(page - 1)} + onPageSizeChange={(pageSize) => table.setPageSize(pageSize)} + /> + + ) : ( + + )} +
+ ) +} diff --git a/src/features/telegram/moderation.constants.ts b/src/features/telegram/moderation.constants.ts new file mode 100644 index 0000000..5024148 --- /dev/null +++ b/src/features/telegram/moderation.constants.ts @@ -0,0 +1,48 @@ +import type { ModerationAudit, ModerationAuditStatus, ModerationAuditType } from "./user-detail/types" + +export const MODERATION_ACTION_LABELS = { + ban: "Ban", + unban: "Unban", + kick: "Kick", + mute: "Mute", + unmute: "Unmute", + delete: "Delete messages", + multi_chat_spam: "Multi-chat spam", + ban_all: "Ban all", + unban_all: "Unban all", +} as const satisfies Record + +export const MODERATION_STATUS_LABELS = { + pending: "Pending", + running: "Running", + completed: "Completed", + partial: "Partial", + failed: "Failed", +} as const satisfies Record + +export function moderationStatusBadgeVariant( + status: ModerationAuditStatus +): "default" | "secondary" | "destructive" | "outline" { + if (status === "failed") return "destructive" + if (status === "pending" || status === "partial") return "outline" + if (status === "running") return "secondary" + return "default" +} + +export function moderationUserName( + user: ModerationAudit["admin"] | ModerationAudit["target"], + fallbackId: number +): string { + if (!user) return `Telegram user ${fallbackId}` + return [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username || `Telegram user ${fallbackId}` +} + +export function shouldShowDeletedMessageCount(audit: ModerationAudit): boolean { + return ( + audit.deletedMessageCount === null || + audit.deletedMessageCount > 0 || + audit.type === "delete" || + audit.type === "ban_all" || + audit.type === "unban_all" + ) +} diff --git a/src/features/telegram/moderation.functions.ts b/src/features/telegram/moderation.functions.ts new file mode 100644 index 0000000..1bb92eb --- /dev/null +++ b/src/features/telegram/moderation.functions.ts @@ -0,0 +1,24 @@ +import { createServerFn } from "@tanstack/react-start" +import { z } from "zod" + +import { adminMiddleware } from "@/server/auth.middleware" + +import { moderationAuditSchema, type ModerationAudit } from "./user-detail/types" + +type ModerationAuditRouter = { + getAll: { + query: (input: { limit: number }) => Promise + } +} + +export const getModerationAudits = createServerFn() + .middleware([adminMiddleware]) + .handler(async ({ context }) => { + // The backend package is released separately from the dashboard. This cast can + // go away once the package version containing auditLog.getAll is published. + const untypedAuditLog: unknown = context.backend.tg.auditLog + // SAFETY: Backend deployment and database migration precede the dashboard release. + const auditLog = untypedAuditLog as ModerationAuditRouter + const audits = await auditLog.getAll.query({ limit: 200 }) + return z.array(moderationAuditSchema).parse(audits) + }) diff --git a/src/features/telegram/user-detail/profile.tsx b/src/features/telegram/user-detail/profile.tsx index efad359..6424bb9 100644 --- a/src/features/telegram/user-detail/profile.tsx +++ b/src/features/telegram/user-detail/profile.tsx @@ -18,6 +18,13 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { + MODERATION_ACTION_LABELS, + MODERATION_STATUS_LABELS, + moderationStatusBadgeVariant, + moderationUserName, + shouldShowDeletedMessageCount, +} from "../moderation.constants" import { InterruptGrantDialog } from "./grant-dialogs" import { AddGroupAdminDialog, RemoveGroupAdminDialog } from "./group-admin-dialog" import { RoleDialog } from "./role-dialog" @@ -254,15 +261,24 @@ export function TelegramUserProfile({ data, canWrite }: { data: TelegramUserDeta {message.message}

- Message #{message.messageId} - - Open message - + + Message #{message.messageId} + {message.deletedAt && Deleted} + + {message.deletedAt ? ( + + ) : ( + + Open message + + )}
@@ -277,10 +293,28 @@ export function TelegramUserProfile({ data, canWrite }: { data: TelegramUserDeta
-

{audit.type}

+
+

{MODERATION_ACTION_LABELS[audit.type]}

+ + {MODERATION_STATUS_LABELS[audit.status]} + +

{audit.reason ?? "No reason provided"}

+
+ {moderationUserName(audit.admin, audit.adminId)} + {audit.totalGroupCount > 0 && ( + + {audit.successGroupCount} succeeded, {audit.failedGroupCount} failed of {audit.totalGroupCount} + + )} + {shouldShowDeletedMessageCount(audit) && ( + + {audit.deletedMessageCount ?? "Count unavailable"} + + )} +
{audit.groupTitle && ( {audit.groupTitle} · {audit.groupId} diff --git a/src/features/telegram/user-detail/types.ts b/src/features/telegram/user-detail/types.ts index b3e4924..d9b8db4 100644 --- a/src/features/telegram/user-detail/types.ts +++ b/src/features/telegram/user-detail/types.ts @@ -1,13 +1,67 @@ +import { z } from "zod" + import type { ApiOutput, TgGrant, TgUser, TgUserRole } from "@/lib/api/types" +export const moderationAuditStatusSchema = z.enum(["pending", "running", "completed", "partial", "failed"]) +export type ModerationAuditStatus = z.infer + +export const moderationAuditTypeSchema = z.enum([ + "ban", + "unban", + "kick", + "mute", + "unmute", + "delete", + "multi_chat_spam", + "ban_all", + "unban_all", +]) +export type ModerationAuditType = z.infer + +type BackendMessage = NonNullable[number] + +const moderationAuditUserSchema = z.object({ + id: z.number(), + firstName: z.string(), + lastName: z.string().optional(), + username: z.string().optional(), + isBot: z.boolean(), + langCode: z.string().optional(), +}) + +export const moderationAuditSchema = z.object({ + id: z.number(), + adminId: z.number(), + targetId: z.number(), + groupId: z.number().nullable(), + type: moderationAuditTypeSchema, + status: moderationAuditStatusSchema, + until: z.date().nullable(), + reason: z.string().nullable(), + deletedMessageCount: z.number().nullable(), + totalGroupCount: z.number(), + successGroupCount: z.number(), + failedGroupCount: z.number(), + createdAt: z.date(), + updatedAt: z.date(), + groupTitle: z.string().optional(), + admin: moderationAuditUserSchema.nullable(), + target: moderationAuditUserSchema.nullable().optional(), +}) +export type ModerationAudit = z.infer + +export type TelegramMessage = BackendMessage & { + deletedAt?: Date | string | null +} + export type TelegramUserDetail = { user: TgUser roles: NonNullable configuredRoles: TgUserRole[] groupAdmin: ApiOutput["tg"]["permissions"]["getRoles"]["groupAdmin"] groups: ApiOutput["tg"]["groups"]["getAll"] - messages: NonNullable - audits: ApiOutput["tg"]["auditLog"]["getById"] + messages: TelegramMessage[] + audits: ModerationAudit[] ongoingGrant: TgGrant | null scheduledGrants: TgGrant[] } diff --git a/src/features/telegram/users.functions.ts b/src/features/telegram/users.functions.ts index da4164a..7b96cf5 100644 --- a/src/features/telegram/users.functions.ts +++ b/src/features/telegram/users.functions.ts @@ -6,6 +6,8 @@ import { z } from "zod" import type { TgUser } from "@/lib/api/types" import { adminMiddleware, writeAdminMiddleware } from "@/server/auth.middleware" +import { moderationAuditSchema } from "./user-detail/types" + export const getTelegramUsers = createServerFn() .middleware([adminMiddleware]) .handler(async ({ context }) => { @@ -71,6 +73,7 @@ export const getTelegramUserDetails = createServerFn() .filter((record) => record.grant.userId === user.id) .map((record) => record.grant) .sort((left, right) => new Date(left.validSince).getTime() - new Date(right.validSince).getTime()) + const moderationAudits = z.array(moderationAuditSchema).parse(audits) return { user, @@ -79,7 +82,7 @@ export const getTelegramUserDetails = createServerFn() groupAdmin: permissions.groupAdmin.filter((group) => group !== null), groups, messages: messages.messages ?? [], - audits, + audits: moderationAudits, ongoingGrant: ongoingGrant.grant ?? null, scheduledGrants: userScheduledGrants, } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index dde2f3f..2d905ac 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as DashboardWebGuidesRouteImport } from './routes/dashboard/web/g import { Route as DashboardWebGroupLabelsRouteImport } from './routes/dashboard/web/group-labels' import { Route as DashboardWebFaqsRouteImport } from './routes/dashboard/web/faqs' import { Route as DashboardWebAssociationsRouteImport } from './routes/dashboard/web/associations' +import { Route as DashboardTelegramModerationRouteImport } from './routes/dashboard/telegram/moderation' import { Route as DashboardTelegramGroupsRouteImport } from './routes/dashboard/telegram/groups' import { Route as DashboardTelegramGrantsRouteImport } from './routes/dashboard/telegram/grants' import { Route as DashboardAzureMembersRouteImport } from './routes/dashboard/azure/members' @@ -104,6 +105,12 @@ const DashboardWebAssociationsRoute = path: '/associations', getParentRoute: () => DashboardWebRoute, } as any) +const DashboardTelegramModerationRoute = + DashboardTelegramModerationRouteImport.update({ + id: '/telegram/moderation', + path: '/telegram/moderation', + getParentRoute: () => DashboardRoute, + } as any) const DashboardTelegramGroupsRoute = DashboardTelegramGroupsRouteImport.update({ id: '/telegram/groups', path: '/telegram/groups', @@ -168,6 +175,7 @@ export interface FileRoutesByFullPath { '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute + '/dashboard/telegram/moderation': typeof DashboardTelegramModerationRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute '/dashboard/web/faqs': typeof DashboardWebFaqsRoute '/dashboard/web/group-labels': typeof DashboardWebGroupLabelsRoute @@ -192,6 +200,7 @@ export interface FileRoutesByTo { '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute + '/dashboard/telegram/moderation': typeof DashboardTelegramModerationRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute '/dashboard/web/faqs': typeof DashboardWebFaqsRoute '/dashboard/web/group-labels': typeof DashboardWebGroupLabelsRoute @@ -218,6 +227,7 @@ export interface FileRoutesById { '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute + '/dashboard/telegram/moderation': typeof DashboardTelegramModerationRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute '/dashboard/web/faqs': typeof DashboardWebFaqsRoute '/dashboard/web/group-labels': typeof DashboardWebGroupLabelsRoute @@ -245,6 +255,7 @@ export interface FileRouteTypes { | '/dashboard/azure/members' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' + | '/dashboard/telegram/moderation' | '/dashboard/web/associations' | '/dashboard/web/faqs' | '/dashboard/web/group-labels' @@ -269,6 +280,7 @@ export interface FileRouteTypes { | '/dashboard/azure/members' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' + | '/dashboard/telegram/moderation' | '/dashboard/web/associations' | '/dashboard/web/faqs' | '/dashboard/web/group-labels' @@ -294,6 +306,7 @@ export interface FileRouteTypes { | '/dashboard/azure/members' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' + | '/dashboard/telegram/moderation' | '/dashboard/web/associations' | '/dashboard/web/faqs' | '/dashboard/web/group-labels' @@ -415,6 +428,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardWebAssociationsRouteImport parentRoute: typeof DashboardWebRoute } + '/dashboard/telegram/moderation': { + id: '/dashboard/telegram/moderation' + path: '/telegram/moderation' + fullPath: '/dashboard/telegram/moderation' + preLoaderRoute: typeof DashboardTelegramModerationRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/telegram/groups': { id: '/dashboard/telegram/groups' path: '/telegram/groups' @@ -513,6 +533,7 @@ interface DashboardRouteChildren { DashboardAzureMembersRoute: typeof DashboardAzureMembersRoute DashboardTelegramGrantsRoute: typeof DashboardTelegramGrantsRoute DashboardTelegramGroupsRoute: typeof DashboardTelegramGroupsRoute + DashboardTelegramModerationRoute: typeof DashboardTelegramModerationRoute DashboardWhatsappGroupsRoute: typeof DashboardWhatsappGroupsRoute DashboardTelegramUsersUserIdRoute: typeof DashboardTelegramUsersUserIdRoute DashboardTelegramUsersIndexRoute: typeof DashboardTelegramUsersIndexRoute @@ -526,6 +547,7 @@ const DashboardRouteChildren: DashboardRouteChildren = { DashboardAzureMembersRoute: DashboardAzureMembersRoute, DashboardTelegramGrantsRoute: DashboardTelegramGrantsRoute, DashboardTelegramGroupsRoute: DashboardTelegramGroupsRoute, + DashboardTelegramModerationRoute: DashboardTelegramModerationRoute, DashboardWhatsappGroupsRoute: DashboardWhatsappGroupsRoute, DashboardTelegramUsersUserIdRoute: DashboardTelegramUsersUserIdRoute, DashboardTelegramUsersIndexRoute: DashboardTelegramUsersIndexRoute, diff --git a/src/routes/dashboard/telegram/moderation.tsx b/src/routes/dashboard/telegram/moderation.tsx new file mode 100644 index 0000000..1bfcc01 --- /dev/null +++ b/src/routes/dashboard/telegram/moderation.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { DataPageSkeleton } from "@/components/loading-skeleton" +import { TelegramModerationPage } from "@/features/telegram/moderation-page" +import { getModerationAudits } from "@/features/telegram/moderation.functions" + +export const Route = createFileRoute("/dashboard/telegram/moderation")({ + loader: () => getModerationAudits(), + pendingComponent: () => , + component: TelegramModerationRoute, +}) + +function TelegramModerationRoute() { + return +}