From 99dce84de111a48cfabdc99eaace86b5c22815d0 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Fri, 19 Jun 2026 17:14:36 +0200 Subject: [PATCH] Modtool report support (#518) * :sparkles: Add support for modtool meta data parsing * :sparkles: Add batch link --- app/reports/[id]/page-content.tsx | 15 ++ components/reports/ModToolContextPanel.tsx | 152 ++++++++++++++++++ components/reports/ReportBatchLink.tsx | 44 +++++ components/reports/modToolRegistry.ts | 126 +++++++++++++++ components/reports/useReportCreationEvent.ts | 25 +++ .../components/reports/ReportBatchLink.cy.tsx | 52 ++++++ lib/constants.ts | 8 + 7 files changed, 422 insertions(+) create mode 100644 components/reports/ModToolContextPanel.tsx create mode 100644 components/reports/ReportBatchLink.tsx create mode 100644 components/reports/modToolRegistry.ts create mode 100644 components/reports/useReportCreationEvent.ts create mode 100644 cypress/components/reports/ReportBatchLink.cy.tsx diff --git a/app/reports/[id]/page-content.tsx b/app/reports/[id]/page-content.tsx index 248bf88..b39809a 100644 --- a/app/reports/[id]/page-content.tsx +++ b/app/reports/[id]/page-content.tsx @@ -82,6 +82,9 @@ import { useUnassignModerator, } from 'components/reports/hooks' import { useAssignmentPolling } from 'components/reports/useAssignmentPolling' +import { ModToolContextPanel } from 'components/reports/ModToolContextPanel' +import { ReportBatchLink } from 'components/reports/ReportBatchLink' +import { useReportCreationEvent } from 'components/reports/useReportCreationEvent' import { getHandleFromSubjectView } from 'components/reports/utils' import { SubjectTag } from 'components/tags/SubjectTag' import { WorkspacePanel } from 'components/workspace/Panel' @@ -152,6 +155,11 @@ function ReportInfoPanel({ const reporterHandle = getHandleFromSubjectView(report.reporter) const createdAt = new Date(report.createdAt) + // The report-creating event carries modTool context for reports filed by + // external intake tools (e.g. fieldkit). The panel renders nothing unless the + // tool is in the registry, so this is a no-op for ordinary reports. + const { data: creationEvent } = useReportCreationEvent(report.eventId) + const moderator = assignment?.moderator const isAssignedToMe = !!assignment && assignment.did === labelerAgent.did @@ -210,6 +218,13 @@ function ReportInfoPanel({ )} + {/* Batch actions link, shown for any report whose creating event carries + a modTool batchId (not just registered intake tools). */} + + + {/* External intake tool context (e.g. fieldkit) */} + + {/* Assignment */}
{assignment ? ( diff --git a/components/reports/ModToolContextPanel.tsx b/components/reports/ModToolContextPanel.tsx new file mode 100644 index 0000000..790c0ea --- /dev/null +++ b/components/reports/ModToolContextPanel.tsx @@ -0,0 +1,152 @@ +'use client' +import { useState } from 'react' +import { + ArrowTopRightOnSquareIcon, + ChevronDownIcon, + ChevronRightIcon, +} from '@heroicons/react/20/solid' +import { ToolsOzoneModerationDefs } from '@atproto/api' +import { MOD_TOOL_REGISTRY } from '@/lib/constants' +import { + ModToolFieldConfig, + badgeColorClass, + buildAdminUrl, + getModToolConfig, + resolvePath, +} from './modToolRegistry' + +// Render a meta value in a moderator-friendly way. Booleans become Yes/No, +// arrays are listed one per line, objects fall back to compact JSON. +function renderValue(value: unknown) { + if (typeof value === 'boolean') return value ? 'Yes' : 'No' + if (Array.isArray(value)) { + return ( +
+ {value.map((item, i) => ( + + {typeof item === 'object' ? JSON.stringify(item) : String(item)} + + ))} +
+ ) + } + if (value && typeof value === 'object') return JSON.stringify(value) + return String(value) +} + +function isEmpty(value: unknown): boolean { + return ( + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && value.length === 0) + ) +} + +// Resolve the curated rows to show. With a `fields` config, surface those +// dot-paths in order, skipping empties. Without one, fall back to showing every +// top-level meta entry so nothing is hidden for unconfigured tools. +function resolveRows( + meta: Record, + fields?: ModToolFieldConfig[], +): { label: string; value: unknown }[] { + if (fields?.length) { + return fields + .map((f) => ({ label: f.label, value: resolvePath(meta, f.path) })) + .filter((row) => !isEmpty(row.value)) + } + return Object.entries(meta) + .filter(([, value]) => !isEmpty(value)) + .map(([label, value]) => ({ label, value })) +} + +// Context panel shown on the report detail page for reports that originated +// from a registered external intake tool (e.g. fieldkit's TIDA/NCII form). +// Renders nothing when there's no modTool or no matching registry entry, so +// ordinary reports are unaffected. +export function ModToolContextPanel({ + modTool, +}: { + modTool?: ToolsOzoneModerationDefs.ModTool +}) { + const [showRaw, setShowRaw] = useState(false) + + const config = getModToolConfig(MOD_TOOL_REGISTRY, modTool?.name) + if (!modTool || !config) return null + + const meta = (modTool.meta ?? {}) as Record + const rows = resolveRows(meta, config.fields) + const adminUrl = buildAdminUrl(config.adminUrlTemplate, meta) + const hasMeta = Object.keys(meta).length > 0 + + return ( +
+
+

+ Reported via +

+ + {config.label} + + {adminUrl && ( + + View submission + + + )} +
+ + {rows.length > 0 && ( +
+ {rows.map((row) => ( +
+
+ {row.label} +
+
+ {renderValue(row.value)} +
+
+ ))} +
+ )} + + {/* When fields are curated, offer the full payload behind a toggle so + moderators can still inspect everything. */} + {hasMeta && !!config.fields?.length && ( +
+ + {showRaw && ( +
+
{JSON.stringify(meta, null, 2)}
+
+ )} +
+ )} +
+ ) +} diff --git a/components/reports/ReportBatchLink.tsx b/components/reports/ReportBatchLink.tsx new file mode 100644 index 0000000..8addaa2 --- /dev/null +++ b/components/reports/ReportBatchLink.tsx @@ -0,0 +1,44 @@ +'use client' +import { WrenchIcon } from '@heroicons/react/20/solid' +import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' +import { ToolsOzoneModerationDefs } from '@atproto/api' + +// Always-on batch link for the report detail page. When a report's creating +// event carries a modTool with a `batchId` in its meta, surface a link to the +// batch actions page (/events/batch/{batchId}). This is independent of the +// MOD_TOOL_REGISTRY, so it works for every tool โ€” not just registered intake +// tools like fieldkit's TIDA form. Renders nothing when there's no batchId, +// so ordinary reports are unaffected. +// +// Mirrors the link convention in components/mod-event/ModToolInfo.tsx (tool +// name chip + external-link icon, opens in a new tab) so the report page stays +// consistent with the event timeline and scheduled actions table. +export function ReportBatchLink({ + modTool, +}: { + modTool?: ToolsOzoneModerationDefs.ModTool +}) { + const batchId = modTool?.meta?.batchId + if (typeof batchId !== 'string' || !batchId) return null + + return ( +
+
+ + + {modTool.name} + + + View batch actions + + +
+
+ ) +} diff --git a/components/reports/modToolRegistry.ts b/components/reports/modToolRegistry.ts new file mode 100644 index 0000000..3e29ee2 --- /dev/null +++ b/components/reports/modToolRegistry.ts @@ -0,0 +1,126 @@ +// Registry that lets Ozone show richer, tool-specific context for reports that +// originate from external intake tools (e.g. fieldkit's TIDA/NCII intake form). +// +// A report created by an external tool carries a `modTool` on its +// report-creating moderation event: `{ name, meta }`. This registry maps a +// `modTool.name` to display config so the report detail page can render a +// human-friendly panel (label, curated field table, deep link back to the +// originating tool) instead of a raw JSON blob. +// +// The registry is configured entirely via the NEXT_PUBLIC_MOD_TOOL_REGISTRY +// env var (parsed in lib/constants.ts), mirroring NEXT_PUBLIC_QUEUE_CONFIG. +// Onboarding a new intake tool is a config change, not a code change. + +// A single curated field to surface from `modTool.meta`. `path` is a dot-path +// resolved against the meta object (e.g. "fields.contact.submitterRole"). +export type ModToolFieldConfig = { + path: string + label: string +} + +export type ModToolConfig = { + // Human-friendly name shown on the badge, e.g. "fieldkit ยท TIDA intake". + label: string + // One of the supported badge colors below. Defaults to "gray" if omitted or + // unknown. Kept as a fixed enum so Tailwind can statically see the classes. + color?: ModToolBadgeColor + // Optional deep link back to the originating tool. Supports {placeholder} + // substitution from top-level meta keys, e.g. + // "https://fieldkit.example.com/admin/submissions/{submissionId}". + adminUrlTemplate?: string + // Optional ordered list of curated fields to surface. When present, only + // these (non-empty) fields are shown as a table and the full meta is offered + // behind a "raw" toggle. When absent, all meta entries are auto-rendered. + fields?: ModToolFieldConfig[] +} + +export type ModToolRegistry = Record + +export type ModToolBadgeColor = + | 'gray' + | 'rose' + | 'blue' + | 'green' + | 'amber' + | 'purple' + | 'teal' + +// Static class strings per color so Tailwind's JIT can see them. Do not build +// these dynamically (e.g. `bg-${color}-100`) or they'll be purged in prod. +export const MOD_TOOL_BADGE_COLORS: Record = { + gray: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200', + rose: 'bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-200', + blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + green: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + amber: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', + purple: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + teal: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200', +} + +export function badgeColorClass(color?: string): string { + return ( + MOD_TOOL_BADGE_COLORS[color as ModToolBadgeColor] ?? + MOD_TOOL_BADGE_COLORS.gray + ) +} + +// Parse the raw env JSON into a registry. Never throws: malformed config logs a +// warning and yields an empty registry so the report page is unaffected. +export function parseModToolRegistry(raw?: string): ModToolRegistry { + if (!raw || !raw.trim()) return {} + try { + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + console.warn( + 'NEXT_PUBLIC_MOD_TOOL_REGISTRY must be a JSON object keyed by modTool name', + ) + return {} + } + return parsed as ModToolRegistry + } catch (err) { + console.warn('Failed to parse NEXT_PUBLIC_MOD_TOOL_REGISTRY:', err) + return {} + } +} + +export function getModToolConfig( + registry: ModToolRegistry, + name?: string, +): ModToolConfig | undefined { + if (!name) return undefined + return registry[name] +} + +// Resolve a dot-path against an object, e.g. resolvePath(meta, "fields.a.b"). +// Returns undefined if any segment is missing or a non-object is traversed. +export function resolvePath(obj: unknown, path: string): unknown { + if (!obj || typeof obj !== 'object') return undefined + return path.split('.').reduce((acc, key) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[key] + } + return undefined + }, obj) +} + +// Build an admin deep link from a template + meta. Returns undefined unless a +// template is provided and every {placeholder} resolves to a non-empty value, +// so we never render a half-substituted (broken) link. +export function buildAdminUrl( + template: string | undefined, + meta: Record | undefined, +): string | undefined { + if (!template) return undefined + const placeholders = template.match(/\{([^}]+)\}/g) + if (!placeholders) return template + let ok = true + const url = template.replace(/\{([^}]+)\}/g, (_, key: string) => { + const value = resolvePath(meta, key) + if (value === undefined || value === null || value === '') { + ok = false + return '' + } + return encodeURIComponent(String(value)) + }) + return ok ? url : undefined +} diff --git a/components/reports/useReportCreationEvent.ts b/components/reports/useReportCreationEvent.ts new file mode 100644 index 0000000..a1008a0 --- /dev/null +++ b/components/reports/useReportCreationEvent.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query' +import { ToolsOzoneModerationDefs } from '@atproto/api' +import { useLabelerAgent } from '@/shell/ConfigurationContext' + +// Fetches the moderation event that created a report (ReportView.eventId). +// That event is where an external intake tool's `modTool` ({ name, meta }) +// lives โ€” ReportView itself doesn't carry modTool โ€” so this is how the report +// detail page recovers the originating tool's context. +// +// Lazily enabled and cached: it only fires when an eventId is present, and a +// failure is non-fatal (the caller treats a missing event as "no context"). +export const useReportCreationEvent = (eventId?: number) => { + const labelerAgent = useLabelerAgent() + return useQuery({ + enabled: !!eventId, + queryKey: ['modEvent', eventId], + staleTime: 5 * 60 * 1000, + queryFn: async (): Promise => { + const { data } = await labelerAgent.tools.ozone.moderation.getEvent({ + id: eventId!, + }) + return data + }, + }) +} diff --git a/cypress/components/reports/ReportBatchLink.cy.tsx b/cypress/components/reports/ReportBatchLink.cy.tsx new file mode 100644 index 0000000..fb70e33 --- /dev/null +++ b/cypress/components/reports/ReportBatchLink.cy.tsx @@ -0,0 +1,52 @@ +/// + +import React from 'react' +import '../../../styles/globals.css' +import { ReportBatchLink } from '../../../components/reports/ReportBatchLink' + +describe('', () => { + it('links to the batch page when modTool.meta.batchId is present', () => { + cy.mount( + , + ) + + cy.get('a[href="/events/batch/batch-abc-123"]') + .should('have.attr', 'target', '_blank') + // Surfaces the originating tool name alongside the link. + cy.contains('ozone/workspace') + }) + + it('works for any tool name, not just the TIDA intake form', () => { + cy.mount( + , + ) + + cy.get('a[href="/events/batch/tida-999"]').should('exist') + }) + + it('renders nothing when there is no batchId', () => { + cy.mount( + , + ) + cy.get('a').should('not.exist') + }) + + it('renders nothing when meta is absent', () => { + cy.mount() + cy.get('a').should('not.exist') + }) + + it('renders nothing when modTool is absent', () => { + cy.mount() + cy.get('a').should('not.exist') + }) +}) + +export {} diff --git a/lib/constants.ts b/lib/constants.ts index 2aeb991..e3956f1 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,3 +1,5 @@ +import { parseModToolRegistry } from '@/reports/modToolRegistry' + export const OAUTH_SCOPE = 'atproto transition:generic transition:chat.bsky' export const OZONE_SERVICE_DID = @@ -93,3 +95,9 @@ export const STRIKE_TO_SUSPENSION_DURATION_IN_HOURS = export const AUTOMATED_ACTION_EMAIL_IDS = { strike: process.env.NEXT_PUBLIC_STRIKE_EMAIL_TEMPLATE_ID, } + +// Display config for reports created by external intake tools (keyed by +// modTool.name). See components/reports/modToolRegistry.ts. +export const MOD_TOOL_REGISTRY = parseModToolRegistry( + process.env.NEXT_PUBLIC_MOD_TOOL_REGISTRY, +) -- knotmirror 0.0.1