diff --git a/package.json b/package.json index f8f5976..97f7bac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f7c9cc..81aaea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: ^0.4.0 version: 0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@polinetwork/backend': - specifier: ^0.18.1 - version: 0.18.1 + specifier: ^1.1.0 + version: 1.1.0 '@t3-oss/env-core': specifier: ^0.13.10 version: 0.13.11(typescript@6.0.3)(zod@4.3.5) @@ -1278,8 +1278,8 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@polinetwork/backend@0.18.1': - resolution: {integrity: sha512-JMH+twKn7WvjX0y7VK5u5FgBLRa1Z651nlR3BRZW/P8xMlNAgLhuin5wP8UWZ6++QdzvMui9XnXEsojuCxt8wg==} + '@polinetwork/backend@1.1.0': + resolution: {integrity: sha512-fc8XwXEODcD9jhENGLaxG1VRJQp91OGnfmMaw1lGR+qiTsNAW1qX9GWdfRoUMxrcZuL4Xx7FU713TlAr4V8dZA==} '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -6363,7 +6363,7 @@ snapshots: '@pkgr/core@0.3.6': {} - '@polinetwork/backend@0.18.1': {} + '@polinetwork/backend@1.1.0': {} '@polka/url@1.0.0-next.29': {} diff --git a/src/components/dashboard-navigation.ts b/src/components/dashboard-navigation.ts index 450e157..1944b0d 100644 --- a/src/components/dashboard-navigation.ts +++ b/src/components/dashboard-navigation.ts @@ -1,7 +1,9 @@ import { BookOpen, + CircleCheck, CircleQuestionMark, Database, + Flag, FolderKanban, FolderTree, Globe, @@ -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 = { diff --git a/src/components/data-toolbar.tsx b/src/components/data-toolbar.tsx index ea5ea2a..13890d3 100644 --- a/src/components/data-toolbar.tsx +++ b/src/components/data-toolbar.tsx @@ -12,6 +12,7 @@ export function DataToolbar({ total, onSearch, searchPlaceholder, + defaultSearchValue, action, children, eyebrow = "Directory", @@ -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 diff --git a/src/features/group-link-reports/reports-page.tsx b/src/features/group-link-reports/reports-page.tsx new file mode 100644 index 0000000..cd7b904 --- /dev/null +++ b/src/features/group-link-reports/reports-page.tsx @@ -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 + +const TYPE_LABEL = { + tg: "Telegram", + wa: "WhatsApp", +} satisfies Record, string> + +const STATUS_LABEL = { + pending: "Pending", + resolved: "Resolved", + dismissed: "Dismissed", +} satisfies Record + +function groupsPageUrl(type: NonNullable) { + 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() + + 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(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 ( + + ) + } + + return ( + + + + + Reference + Issue + Details + {!showActions && Status} + Date + {showActions && Actions} + + + + {reportGroups.map((group) => { + const report = group.latest + const target = reportTarget(report) + const goToTarget = () => { + if (target) void router.navigate(target) + } + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + goToTarget() + } + } + : undefined + } + > + +
+ + {report.reportType === "broken_link" ? (report.groupTitle ?? "—") : missingLabel(report)} + + {group.count > 1 && ( + + {group.count} + + )} +
+
+ +
+ {report.type && {TYPE_LABEL[report.type]}} + {REPORT_TYPE_LABEL[report.reportType]} +
+
+ + {report.reportType === "broken_link" ? (report.reportedLink ?? "—") : (report.details ?? "—")} + + {!showActions && ( + + + {STATUS_LABEL[report.status]} + + + )} + + {new Date(report.createdAt).toLocaleDateString()} + + {showActions && ( + event.stopPropagation()}> + + + + )} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/features/group-link-reports/reports.functions.ts b/src/features/group-link-reports/reports.functions.ts new file mode 100644 index 0000000..a4a1499 --- /dev/null +++ b/src/features/group-link-reports/reports.functions.ts @@ -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)) diff --git a/src/features/telegram/groups-page.tsx b/src/features/telegram/groups-page.tsx index 84d7407..cd891c5 100644 --- a/src/features/telegram/groups-page.tsx +++ b/src/features/telegram/groups-page.tsx @@ -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([]) const [excludedLabels, setExcludedLabels] = useState([]) @@ -71,6 +73,7 @@ export function TelegramGroupsPage({ count={visibleGroups.length} total={loadedGroups.length} searchPlaceholder="Search by group name or tag…" + defaultSearchValue={initialQuery} onSearch={setQuery} > diff --git a/src/features/whatsapp/whatsapp-groups-page.tsx b/src/features/whatsapp/whatsapp-groups-page.tsx index df2fd20..14160bc 100644 --- a/src/features/whatsapp/whatsapp-groups-page.tsx +++ b/src/features/whatsapp/whatsapp-groups-page.tsx @@ -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([]) const [excludedLabels, setExcludedLabels] = useState([]) const [editingLabelsGroup, setEditingLabelsGroup] = useState(null) @@ -219,6 +221,7 @@ export function WhatsappGroupsPage({ count={visibleGroups.length} total={loadedGroups.length} searchPlaceholder="Search by group name…" + defaultSearchValue={initialQuery} onSearch={setQuery} action={} > diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 1c3c8c9..3583875 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -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] diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index cd4bd23..a18677e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -25,6 +25,8 @@ import { Route as DashboardWebFaqsRouteImport } from './routes/dashboard/web/faq import { Route as DashboardWebAssociationsRouteImport } from './routes/dashboard/web/associations' import { Route as DashboardTelegramGroupsRouteImport } from './routes/dashboard/telegram/groups' import { Route as DashboardTelegramGrantsRouteImport } from './routes/dashboard/telegram/grants' +import { Route as DashboardReportsResolvedRouteImport } from './routes/dashboard/reports/resolved' +import { Route as DashboardReportsGroupLinksRouteImport } from './routes/dashboard/reports/group-links' import { Route as DashboardAzureMembersRouteImport } from './routes/dashboard/azure/members' import { Route as DashboardAzureGroupsRouteImport } from './routes/dashboard/azure/groups' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -115,6 +117,18 @@ const DashboardTelegramGrantsRoute = DashboardTelegramGrantsRouteImport.update({ path: '/telegram/grants', getParentRoute: () => DashboardRoute, } as any) +const DashboardReportsResolvedRoute = + DashboardReportsResolvedRouteImport.update({ + id: '/reports/resolved', + path: '/reports/resolved', + getParentRoute: () => DashboardRoute, + } as any) +const DashboardReportsGroupLinksRoute = + DashboardReportsGroupLinksRouteImport.update({ + id: '/reports/group-links', + path: '/reports/group-links', + getParentRoute: () => DashboardRoute, + } as any) const DashboardAzureMembersRoute = DashboardAzureMembersRouteImport.update({ id: '/azure/members', path: '/azure/members', @@ -172,6 +186,8 @@ export interface FileRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute + '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute + '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute @@ -197,6 +213,8 @@ export interface FileRoutesByTo { '/api/auth/$': typeof ApiAuthSplatRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute + '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute + '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute @@ -224,6 +242,8 @@ export interface FileRoutesById { '/api/auth/$': typeof ApiAuthSplatRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute + '/dashboard/reports/group-links': typeof DashboardReportsGroupLinksRoute + '/dashboard/reports/resolved': typeof DashboardReportsResolvedRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute '/dashboard/web/associations': typeof DashboardWebAssociationsRoute @@ -252,6 +272,8 @@ export interface FileRouteTypes { | '/api/auth/$' | '/dashboard/azure/groups' | '/dashboard/azure/members' + | '/dashboard/reports/group-links' + | '/dashboard/reports/resolved' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' | '/dashboard/web/associations' @@ -277,6 +299,8 @@ export interface FileRouteTypes { | '/api/auth/$' | '/dashboard/azure/groups' | '/dashboard/azure/members' + | '/dashboard/reports/group-links' + | '/dashboard/reports/resolved' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' | '/dashboard/web/associations' @@ -303,6 +327,8 @@ export interface FileRouteTypes { | '/api/auth/$' | '/dashboard/azure/groups' | '/dashboard/azure/members' + | '/dashboard/reports/group-links' + | '/dashboard/reports/resolved' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' | '/dashboard/web/associations' @@ -441,6 +467,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardTelegramGrantsRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/reports/resolved': { + id: '/dashboard/reports/resolved' + path: '/reports/resolved' + fullPath: '/dashboard/reports/resolved' + preLoaderRoute: typeof DashboardReportsResolvedRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/reports/group-links': { + id: '/dashboard/reports/group-links' + path: '/reports/group-links' + fullPath: '/dashboard/reports/group-links' + preLoaderRoute: typeof DashboardReportsGroupLinksRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/azure/members': { id: '/dashboard/azure/members' path: '/azure/members' @@ -532,6 +572,8 @@ interface DashboardRouteChildren { DashboardIndexRoute: typeof DashboardIndexRoute DashboardAzureGroupsRoute: typeof DashboardAzureGroupsRoute DashboardAzureMembersRoute: typeof DashboardAzureMembersRoute + DashboardReportsGroupLinksRoute: typeof DashboardReportsGroupLinksRoute + DashboardReportsResolvedRoute: typeof DashboardReportsResolvedRoute DashboardTelegramGrantsRoute: typeof DashboardTelegramGrantsRoute DashboardTelegramGroupsRoute: typeof DashboardTelegramGroupsRoute DashboardWhatsappGroupsRoute: typeof DashboardWhatsappGroupsRoute @@ -545,6 +587,8 @@ const DashboardRouteChildren: DashboardRouteChildren = { DashboardIndexRoute: DashboardIndexRoute, DashboardAzureGroupsRoute: DashboardAzureGroupsRoute, DashboardAzureMembersRoute: DashboardAzureMembersRoute, + DashboardReportsGroupLinksRoute: DashboardReportsGroupLinksRoute, + DashboardReportsResolvedRoute: DashboardReportsResolvedRoute, DashboardTelegramGrantsRoute: DashboardTelegramGrantsRoute, DashboardTelegramGroupsRoute: DashboardTelegramGroupsRoute, DashboardWhatsappGroupsRoute: DashboardWhatsappGroupsRoute, diff --git a/src/routes/dashboard/reports/group-links.tsx b/src/routes/dashboard/reports/group-links.tsx new file mode 100644 index 0000000..206c2e7 --- /dev/null +++ b/src/routes/dashboard/reports/group-links.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { DataPageSkeleton } from "@/components/loading-skeleton" +import { GroupLinkReportsPage } from "@/features/group-link-reports/reports-page" +import { getPendingGroupLinkReports } from "@/features/group-link-reports/reports.functions" + +export const Route = createFileRoute("/dashboard/reports/group-links")({ + loader: () => getPendingGroupLinkReports(), + pendingComponent: () => , + component: GroupLinkReportsRoute, +}) + +function GroupLinkReportsRoute() { + const reports = Route.useLoaderData() + return +} diff --git a/src/routes/dashboard/reports/resolved.tsx b/src/routes/dashboard/reports/resolved.tsx new file mode 100644 index 0000000..f233ea9 --- /dev/null +++ b/src/routes/dashboard/reports/resolved.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { DataPageSkeleton } from "@/components/loading-skeleton" +import { GroupLinkReportsPage } from "@/features/group-link-reports/reports-page" +import { getResolvedGroupLinkReports } from "@/features/group-link-reports/reports.functions" + +export const Route = createFileRoute("/dashboard/reports/resolved")({ + loader: () => getResolvedGroupLinkReports(), + pendingComponent: () => , + component: ResolvedGroupLinkReportsRoute, +}) + +function ResolvedGroupLinkReportsRoute() { + const reports = Route.useLoaderData() + return +} diff --git a/src/routes/dashboard/telegram/groups.tsx b/src/routes/dashboard/telegram/groups.tsx index 4208d74..187f094 100644 --- a/src/routes/dashboard/telegram/groups.tsx +++ b/src/routes/dashboard/telegram/groups.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router" +import { z } from "zod" import { DataPageSkeleton } from "@/components/loading-skeleton" import { listGroupLabels, listGroupsWithLabels } from "@/features/group-labels/group-labels.functions" @@ -6,6 +7,7 @@ import { TelegramGroupsPage } from "@/features/telegram/groups-page" import { getTelegramGroups } from "@/features/telegram/groups.functions" export const Route = createFileRoute("/dashboard/telegram/groups")({ + validateSearch: z.object({ q: z.string().optional() }), loader: async () => { const [groups, groupLabels, groupsWithLabels] = await Promise.all([ getTelegramGroups(), @@ -20,11 +22,13 @@ export const Route = createFileRoute("/dashboard/telegram/groups")({ function TelegramGroupsRoute() { const { groups, groupLabels, groupsWithLabels } = Route.useLoaderData() + const { q } = Route.useSearch() return ( ) } diff --git a/src/routes/dashboard/whatsapp/groups.tsx b/src/routes/dashboard/whatsapp/groups.tsx index 20f2cca..8918d50 100644 --- a/src/routes/dashboard/whatsapp/groups.tsx +++ b/src/routes/dashboard/whatsapp/groups.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router" +import { z } from "zod" import { DataPageSkeleton } from "@/components/loading-skeleton" import { listGroupLabels, listGroupsWithLabels } from "@/features/group-labels/group-labels.functions" @@ -6,6 +7,7 @@ import { getWhatsappGroups } from "@/features/whatsapp/groups.functions" import { WhatsappGroupsPage } from "@/features/whatsapp/whatsapp-groups-page" export const Route = createFileRoute("/dashboard/whatsapp/groups")({ + validateSearch: z.object({ q: z.string().optional() }), loader: async () => { const [groups, groupLabels, groupsWithLabels] = await Promise.all([ getWhatsappGroups(), @@ -20,11 +22,13 @@ export const Route = createFileRoute("/dashboard/whatsapp/groups")({ function WhatsappGroupsRoute() { const { groups, groupLabels, groupsWithLabels } = Route.useLoaderData() + const { q } = Route.useSearch() return ( ) }