+ )}
+
+ {/* 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 (
+
+ )
+}
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,
+)