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
+}