Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"@base-ui/react": "^1.6.0",
"@better-auth/passkey": "^1.5.5",
"@dnd-kit/react": "^0.4.0",
"@polinetwork/backend": "^0.18.1",
"@polinetwork/backend": "^1.1.0",
"@t3-oss/env-core": "^0.13.10",
"@tanstack/react-router": "1.170.17",
"@tanstack/react-start": "1.168.27",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions src/components/dashboard-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
BookOpen,
CircleCheck,
CircleQuestionMark,
Database,
Flag,
FolderKanban,
FolderTree,
Globe,
Expand Down Expand Up @@ -76,6 +78,15 @@ export const dashboardNavigation = [
{ title: "Categories", url: "/dashboard/web/groups-by-label", icon: FolderTree },
],
},
{
title: "Reports",
icon: Flag,
iconSrc: undefined,
items: [
{ title: "Reported", url: "/dashboard/reports/group-links", icon: Flag },
{ title: "Resolved", url: "/dashboard/reports/resolved", icon: CircleCheck },
],
},
] as const satisfies readonly DashboardNavigationCategory[]

export const accountNavigation = {
Expand Down
4 changes: 3 additions & 1 deletion src/components/data-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function DataToolbar({
total,
onSearch,
searchPlaceholder,
defaultSearchValue,
action,
children,
eyebrow = "Directory",
Expand All @@ -22,12 +23,13 @@ export function DataToolbar({
total?: number
onSearch?: (value: string) => void
searchPlaceholder?: string
defaultSearchValue?: string
action?: ReactNode
children?: ReactNode
eyebrow?: string
}) {
const searchId = useId()
const [searchValue, setSearchValue] = useState("")
const [searchValue, setSearchValue] = useState(defaultSearchValue ?? "")
const deferredSearchValue = useDeferredValue(searchValue)
const onSearchRef = useRef(onSearch)
onSearchRef.current = onSearch
Expand Down
222 changes: 222 additions & 0 deletions src/features/group-link-reports/reports-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { useRouter } from "@tanstack/react-router"
import { useServerFn } from "@tanstack/react-start"
import { Check, Flag, X } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"

import { EmptyState } from "@/components/empty-state"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table"
import { labelPathToUrlSegments } from "@/features/group-labels/label-tree"
import { dismissGroupLinkReport, resolveGroupLinkReport } from "@/features/group-link-reports/reports.functions"
import type { GroupLinkReport } from "@/lib/api/types"

const REPORT_TYPE_LABEL = {
broken_link: "Broken link",
missing: "Missing group",
} satisfies Record<GroupLinkReport["reportType"], string>

const TYPE_LABEL = {
tg: "Telegram",
wa: "WhatsApp",
} satisfies Record<NonNullable<GroupLinkReport["type"]>, string>

const STATUS_LABEL = {
pending: "Pending",
resolved: "Resolved",
dismissed: "Dismissed",
} satisfies Record<GroupLinkReport["status"], string>

function groupsPageUrl(type: NonNullable<GroupLinkReport["type"]>) {
return type === "tg" ? "/dashboard/telegram/groups" : "/dashboard/whatsapp/groups"
}

function labelCategoryUrl(label: string) {
return `/dashboard/web/groups-by-label/${labelPathToUrlSegments(label).join("/")}`
}

function missingLabel(report: GroupLinkReport) {
return report.label ?? "—"
}

/** Where clicking a report should go: the problematic group itself for a broken link, or the category the
* missing group belongs to, following its label path. */
function reportTarget(report: GroupLinkReport): { to: string; search?: { q: string } } | null {
if (report.reportType === "broken_link") {
if (!report.type) return null
return { to: groupsPageUrl(report.type), search: report.groupTitle ? { q: report.groupTitle } : undefined }
}
if (!report.label) return null
return { to: labelCategoryUrl(report.label) }
}

type ReportGroup = {
key: string
latest: GroupLinkReport
ids: number[]
count: number
}

function groupReports(reports: GroupLinkReport[]): ReportGroup[] {
const groups = new Map<string, ReportGroup>()

for (const report of reports) {
const key =
report.reportType === "broken_link" ? `broken_link:${report.type}:${report.groupId}` : `missing:${report.label}`

const existing = groups.get(key)
if (!existing) {
groups.set(key, { key, latest: report, ids: [report.id], count: 1 })
continue
}

existing.ids.push(report.id)
existing.count += 1
if (new Date(report.createdAt) > new Date(existing.latest.createdAt)) existing.latest = report
}

return [...groups.values()].sort(
(a, b) => new Date(a.latest.createdAt).getTime() - new Date(b.latest.createdAt).getTime()
)
}

export function GroupLinkReportsPage({
loadedReports,
showActions = true,
}: {
loadedReports: GroupLinkReport[]
showActions?: boolean
}) {
const router = useRouter()
const resolve = useServerFn(resolveGroupLinkReport)
const dismiss = useServerFn(dismissGroupLinkReport)
const [pendingKey, setPendingKey] = useState<string | null>(null)

const reportGroups = groupReports(loadedReports)

async function handleAction(group: ReportGroup, action: "resolve" | "dismiss") {
setPendingKey(group.key)
try {
await (action === "resolve" ? resolve({ data: { ids: group.ids } }) : dismiss({ data: { ids: group.ids } }))
await router.invalidate()
toast.success(action === "resolve" ? "Report resolved." : "Report dismissed.")
} catch (error) {
console.error(error)
toast.error("The report could not be updated. Check your permissions and try again.")
} finally {
setPendingKey(null)
}
}

if (loadedReports.length === 0) {
return (
<EmptyState
icon={Flag}
title={showActions ? "No pending reports" : "No resolved reports"}
text="Reports of broken Telegram or WhatsApp links, or missing groups, submitted by users will show up here."
/>
)
}

return (
<TableSurface>
<Table>
<TableHeader>
<TableRow>
<DataTableHead>Reference</DataTableHead>
<DataTableHead>Issue</DataTableHead>
<DataTableHead>Details</DataTableHead>
{!showActions && <DataTableHead>Status</DataTableHead>}
<DataTableHead>Date</DataTableHead>
{showActions && <DataTableHead className="text-right">Actions</DataTableHead>}
</TableRow>
</TableHeader>
<TableBody>
{reportGroups.map((group) => {
const report = group.latest
const target = reportTarget(report)
const goToTarget = () => {
if (target) void router.navigate(target)
}
return (
<TableRow
key={group.key}
className={
target
? "cursor-pointer outline-none focus-visible:bg-muted/70 focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring/25"
: undefined
}
tabIndex={target ? 0 : undefined}
onClick={target ? goToTarget : undefined}
onKeyDown={
target
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
goToTarget()
}
}
: undefined
}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<span>
{report.reportType === "broken_link" ? (report.groupTitle ?? "—") : missingLabel(report)}
</span>
{group.count > 1 && (
<Badge variant="destructive" className="h-5 min-w-5 px-1">
{group.count}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{report.type && <Badge variant="outline">{TYPE_LABEL[report.type]}</Badge>}
<span>{REPORT_TYPE_LABEL[report.reportType]}</span>
</div>
</TableCell>
<TableCell className="max-w-80 truncate text-muted-foreground">
{report.reportType === "broken_link" ? (report.reportedLink ?? "—") : (report.details ?? "—")}
</TableCell>
{!showActions && (
<TableCell>
<Badge variant={report.status === "resolved" ? "default" : "secondary"}>
{STATUS_LABEL[report.status]}
</Badge>
</TableCell>
)}
<TableCell className="text-muted-foreground">
{new Date(report.createdAt).toLocaleDateString()}
</TableCell>
{showActions && (
<TableCell className="flex justify-end gap-1.5" onClick={(event) => event.stopPropagation()}>
<Button
variant="outline"
size="icon-sm"
disabled={pendingKey === group.key}
onClick={() => void handleAction(group, "dismiss")}
>
<X />
<span className="sr-only">Dismiss</span>
</Button>
<Button
size="icon-sm"
disabled={pendingKey === group.key}
onClick={() => void handleAction(group, "resolve")}
>
<Check />
<span className="sr-only">Resolve</span>
</Button>
</TableCell>
)}
</TableRow>
)
})}
</TableBody>
</Table>
</TableSurface>
)
}
22 changes: 22 additions & 0 deletions src/features/group-link-reports/reports.functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createServerFn } from "@tanstack/react-start"
import { z } from "zod"

import { adminMiddleware, groupWriteAdminMiddleware } from "@/server/auth.middleware"

export const getPendingGroupLinkReports = createServerFn()
.middleware([adminMiddleware])
.handler(({ context }) => context.backend.web.reports.list.query({ statuses: ["pending"] }))

export const getResolvedGroupLinkReports = createServerFn()
.middleware([adminMiddleware])
.handler(({ context }) => context.backend.web.reports.list.query({ statuses: ["resolved", "dismissed"] }))

export const resolveGroupLinkReport = createServerFn({ method: "POST" })
.middleware([groupWriteAdminMiddleware])
.validator(z.object({ ids: z.array(z.number().int()).min(1) }))
.handler(({ data, context }) => context.backend.web.reports.resolve.mutate(data))

export const dismissGroupLinkReport = createServerFn({ method: "POST" })
.middleware([groupWriteAdminMiddleware])
.validator(z.object({ ids: z.array(z.number().int()).min(1) }))
.handler(({ data, context }) => context.backend.web.reports.dismiss.mutate(data))
5 changes: 4 additions & 1 deletion src/features/telegram/groups-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ export function TelegramGroupsPage({
loadedGroups,
loadedGroupLabels,
loadedGroupsWithLabels,
initialQuery,
}: {
loadedGroups: TgGroup[]
loadedGroupLabels: TgGroupLabel[]
loadedGroupsWithLabels: GroupWithLabels[]
initialQuery?: string
}) {
const [query, setQuery] = useState("")
const [query, setQuery] = useState(initialQuery ?? "")
const [requiredLabels, setRequiredLabels] = useState<TgGroupLabel[]>([])
const [excludedLabels, setExcludedLabels] = useState<TgGroupLabel[]>([])

Expand Down Expand Up @@ -71,6 +73,7 @@ export function TelegramGroupsPage({
count={visibleGroups.length}
total={loadedGroups.length}
searchPlaceholder="Search by group name or tag…"
defaultSearchValue={initialQuery}
onSearch={setQuery}
>
<Popover>
Expand Down
5 changes: 4 additions & 1 deletion src/features/whatsapp/whatsapp-groups-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@ export function WhatsappGroupsPage({
loadedGroups,
loadedGroupLabels,
loadedGroupsWithLabels,
initialQuery,
}: {
loadedGroups: WaGroup[]
loadedGroupLabels: TgGroupLabel[]
loadedGroupsWithLabels: GroupWithLabels[]
initialQuery?: string
}) {
const router = useRouter()
const setGroupVisibilityFn = useServerFn(setWhatsappGroupVisibility)
const [query, setQuery] = useState("")
const [query, setQuery] = useState(initialQuery ?? "")
const [requiredLabels, setRequiredLabels] = useState<TgGroupLabel[]>([])
const [excludedLabels, setExcludedLabels] = useState<TgGroupLabel[]>([])
const [editingLabelsGroup, setEditingLabelsGroup] = useState<WaGroup | null>(null)
Expand Down Expand Up @@ -219,6 +221,7 @@ export function WhatsappGroupsPage({
count={visibleGroups.length}
total={loadedGroups.length}
searchPlaceholder="Search by group name…"
defaultSearchValue={initialQuery}
onSearch={setQuery}
action={<CreateEditGroupDialog />}
>
Expand Down
2 changes: 2 additions & 0 deletions src/lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ export type WaGroup = ApiOutput["wa"]["groups"]["getAll"][number]

/** A group (Telegram or WhatsApp) with its labels already resolved, from the cross-platform search router. */
export type GroupWithLabels = ApiOutput["groups"]["search"]["getAll"][number]

export type GroupLinkReport = ApiOutput["web"]["reports"]["list"][number]
Loading
Loading