diff --git a/frontend-v2/package.json b/frontend-v2/package.json
index c3c2d092..126f36e0 100644
--- a/frontend-v2/package.json
+++ b/frontend-v2/package.json
@@ -16,6 +16,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@tanstack/pacer-lite": "^0.2.1",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
diff --git a/frontend-v2/pnpm-lock.yaml b/frontend-v2/pnpm-lock.yaml
index 2ebc172d..736f3163 100644
--- a/frontend-v2/pnpm-lock.yaml
+++ b/frontend-v2/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.0.8)(react@19.2.3)
+ '@tanstack/pacer-lite':
+ specifier: ^0.2.1
+ version: 0.2.1
'@tanstack/react-form':
specifier: ^1.27.7
version: 1.27.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -895,6 +898,10 @@ packages:
resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==}
engines: {node: '>=18'}
+ '@tanstack/pacer-lite@0.2.1':
+ resolution: {integrity: sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==}
+ engines: {node: '>=18'}
+
'@tanstack/query-core@5.66.0':
resolution: {integrity: sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==}
@@ -3279,6 +3286,8 @@ snapshots:
'@tanstack/pacer-lite@0.1.1': {}
+ '@tanstack/pacer-lite@0.2.1': {}
+
'@tanstack/query-core@5.66.0': {}
'@tanstack/query-devtools@5.65.0': {}
diff --git a/frontend-v2/src/app/activists/activist-filters.tsx b/frontend-v2/src/app/activists/activist-filters.tsx
new file mode 100644
index 00000000..ca26d364
--- /dev/null
+++ b/frontend-v2/src/app/activists/activist-filters.tsx
@@ -0,0 +1,213 @@
+'use client'
+
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Button } from '@/components/ui/button'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { DatePicker } from '@/components/ui/date-picker'
+import { ChevronDown, X } from 'lucide-react'
+import { format } from 'date-fns'
+
+interface FilterState {
+ showAllChapters: boolean
+ nameSearch: string
+ lastEventLt?: string // ISO date string
+ lastEventGte?: string // ISO date string
+ // Future filters to be added:
+ // includeHidden?: boolean
+}
+
+interface ActivistFiltersProps {
+ filters: FilterState
+ onFiltersChange: (filters: FilterState) => void
+ isAdmin: boolean
+ children?: React.ReactNode
+}
+
+export function ActivistFilters({
+ filters,
+ onFiltersChange,
+ isAdmin,
+ children,
+}: ActivistFiltersProps) {
+ return (
+
+ {/* Name search - always visible */}
+
+ onFiltersChange({ ...filters, nameSearch: e.target.value })
+ }
+ />
+
+ {/* Horizontally scrolling filter buttons */}
+
+ {/* Column selector or other options passed as children */}
+ {children}
+
+ {/* Chapter filter - only for admins */}
+ {isAdmin && (
+
+
+ onFiltersChange({
+ ...filters,
+ showAllChapters: Boolean(checked),
+ })
+ }
+ />
+
+
+ )}
+
+ {/* Last Event filter chip with dropdown */}
+
+
+
+
+
+
+
+
+
+ {
+ onFiltersChange({
+ ...filters,
+ lastEventGte: date
+ ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+ : undefined,
+ })
+ }}
+ placeholder="Select start date"
+ />
+
+
+
+ {
+ onFiltersChange({
+ ...filters,
+ lastEventLt: date
+ ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+ : undefined,
+ })
+ }}
+ placeholder="Select end date"
+ />
+
+ {(filters.lastEventGte || filters.lastEventLt) && (
+
+ )}
+
+
+
+ {(filters.lastEventGte || filters.lastEventLt) && (
+
+ )}
+
+
+
+ )
+}
+
+export type { FilterState }
diff --git a/frontend-v2/src/app/activists/activists-page.tsx b/frontend-v2/src/app/activists/activists-page.tsx
new file mode 100644
index 00000000..34b41d91
--- /dev/null
+++ b/frontend-v2/src/app/activists/activists-page.tsx
@@ -0,0 +1,237 @@
+'use client'
+
+import { useMemo, useState, useEffect } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { liteDebounce } from '@tanstack/pacer-lite'
+import { useSearchParams, useRouter } from 'next/navigation'
+import {
+ apiClient,
+ API_PATH,
+ QueryActivistOptions,
+ ActivistColumnName,
+} from '@/lib/api'
+import { useAuthedPageContext } from '@/hooks/useAuthedPageContext'
+import { ActivistTable } from './activists-table'
+import { ActivistFilters, FilterState } from './activist-filters'
+import { ColumnSelector } from './column-selector'
+import { getDefaultColumns, sortColumnsByDefinitionOrder } from './column-definitions'
+
+export default function ActivistsPage() {
+ const { user } = useAuthedPageContext()
+ const isAdmin = user.role === 'admin'
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ // Parse initial state from URL
+ const getInitialFilters = (): FilterState => {
+ return {
+ showAllChapters: searchParams.get('showAllChapters') === 'true',
+ nameSearch: searchParams.get('nameSearch') || '',
+ lastEventGte: searchParams.get('lastEventGte') || undefined,
+ lastEventLt: searchParams.get('lastEventLt') || undefined,
+ }
+ }
+
+ const getInitialColumns = (): ActivistColumnName[] => {
+ const columnsParam = searchParams.get('columns')
+ if (columnsParam) {
+ const parsed = columnsParam.split(',') as ActivistColumnName[]
+ return sortColumnsByDefinitionOrder(parsed)
+ }
+ return getDefaultColumns(false)
+ }
+
+ // Filter state
+ const [filters, setFilters] = useState(getInitialFilters)
+
+ // Column state
+ const [visibleColumns, setVisibleColumns] =
+ useState(getInitialColumns)
+
+ // Debounced name search (for API queries)
+ const [debouncedNameSearch, setDebouncedNameSearch] = useState(
+ filters.nameSearch,
+ )
+
+ // Create debounced setter with @tanstack/pacer-lite
+ const debouncedSetNameSearch = useMemo(
+ () =>
+ liteDebounce((value: string) => setDebouncedNameSearch(value), {
+ wait: 300,
+ }),
+ [],
+ )
+
+ // Update debounced search when filters change
+ useEffect(() => {
+ debouncedSetNameSearch(filters.nameSearch)
+ }, [filters.nameSearch, debouncedSetNameSearch])
+
+ // Update URL when filters or columns change
+ useEffect(() => {
+ const params = new URLSearchParams()
+
+ // Add filter params
+ if (filters.showAllChapters) {
+ params.set('showAllChapters', 'true')
+ }
+ if (filters.nameSearch) {
+ params.set('nameSearch', filters.nameSearch)
+ }
+ if (filters.lastEventGte) {
+ params.set('lastEventGte', filters.lastEventGte)
+ }
+ if (filters.lastEventLt) {
+ params.set('lastEventLt', filters.lastEventLt)
+ }
+
+ // Add columns param (exclude chapter_name and name as they're auto-added)
+ const columnsToStore = visibleColumns.filter(
+ (col) => col !== 'chapter_name' && col !== 'name'
+ )
+ if (columnsToStore.length > 0) {
+ params.set('columns', columnsToStore.join(','))
+ }
+
+ // Update URL without causing navigation
+ const newUrl = params.toString() ? `?${params.toString()}` : '/activists'
+ router.replace(newUrl, { scroll: false })
+ }, [filters, visibleColumns, router])
+
+ // Build query options (using debounced search)
+ const queryOptions = useMemo(() => {
+ // Determine columns to request from API
+ const columnsToRequest = [...visibleColumns]
+
+ // Add chapter_name if showing all chapters and not already included
+ if (filters.showAllChapters && !columnsToRequest.includes('chapter_name')) {
+ columnsToRequest.unshift('chapter_name')
+ }
+
+ // Always include ID for row keys
+ if (!columnsToRequest.includes('id')) {
+ columnsToRequest.unshift('id')
+ }
+
+ return {
+ columns: columnsToRequest,
+ filters: {
+ chapter_id: filters.showAllChapters ? 0 : user.ChapterID,
+ name: debouncedNameSearch
+ ? { name_contains: debouncedNameSearch }
+ : undefined,
+ last_event:
+ filters.lastEventGte || filters.lastEventLt
+ ? {
+ last_event_gte: filters.lastEventGte,
+ last_event_lt: filters.lastEventLt,
+ }
+ : undefined,
+ },
+ }
+ }, [
+ filters.showAllChapters,
+ filters.lastEventGte,
+ filters.lastEventLt,
+ debouncedNameSearch,
+ visibleColumns,
+ user.ChapterID,
+ ])
+
+ // Fetch activists
+ const { data, isLoading, isError, error } = useQuery({
+ queryKey: [API_PATH.ACTIVISTS_SEARCH, queryOptions],
+ queryFn: () => apiClient.searchActivists(queryOptions),
+ })
+
+ // Update visible columns when showAllChapters changes
+ const handleFiltersChange = (newFilters: FilterState) => {
+ setFilters(newFilters)
+
+ // Only update chapter_name column visibility, preserve other selections
+ if (newFilters.showAllChapters !== filters.showAllChapters) {
+ setVisibleColumns((currentCols) => {
+ const hasChapterName = currentCols.includes('chapter_name')
+
+ if (newFilters.showAllChapters && !hasChapterName) {
+ // Add chapter_name at the beginning
+ return ['chapter_name', ...currentCols]
+ } else if (!newFilters.showAllChapters && hasChapterName) {
+ // Remove chapter_name
+ return currentCols.filter((col) => col !== 'chapter_name')
+ }
+
+ return currentCols
+ })
+ }
+ }
+
+ // Get display columns (for table rendering)
+ const displayColumns = useMemo(() => {
+ const cols = [...visibleColumns]
+
+ // Add chapter_name if showing all chapters and not already included
+ if (filters.showAllChapters && !cols.includes('chapter_name')) {
+ cols.unshift('chapter_name')
+ }
+
+ return cols
+ }, [visibleColumns, filters.showAllChapters])
+
+ return (
+
+ {/* Header */}
+
+
Activists
+
+ Search and manage activists in your chapter
+
+
+
+ {/* Filters */}
+
+
+
+
+ {/* Loading state */}
+ {isLoading && (
+
+ Loading activists...
+
+ )}
+
+ {/* Error state */}
+ {isError && (
+
+ {error instanceof Error
+ ? error.message
+ : 'Failed to load activists. Please try again.'}
+
+ )}
+
+ {/* Results count */}
+ {data && !isLoading && (
+
+ {data.activists.length} activist
+ {data.activists.length !== 1 ? 's' : ''} shown
+
+ )}
+
+ {/* Table */}
+ {data && !isLoading && (
+
+ )}
+
+ )
+}
diff --git a/frontend-v2/src/app/activists/activists-table.tsx b/frontend-v2/src/app/activists/activists-table.tsx
new file mode 100644
index 00000000..84636310
--- /dev/null
+++ b/frontend-v2/src/app/activists/activists-table.tsx
@@ -0,0 +1,166 @@
+'use client'
+
+import { useMemo } from 'react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ useReactTable,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { ActivistJSON, ActivistColumnName } from '@/lib/api'
+import { COLUMN_DEFINITIONS } from './column-definitions'
+
+interface ActivistTableProps {
+ activists: ActivistJSON[]
+ visibleColumns: ActivistColumnName[]
+}
+
+const formatValue = (value: unknown, columnName: ActivistColumnName): string => {
+ if (value === null || value === undefined) return ''
+
+ // Boolean values
+ if (typeof value === 'boolean') {
+ return value ? 'Yes' : 'No'
+ }
+
+ // Number values
+ if (typeof value === 'number') {
+ return value.toString()
+ }
+
+ // Date formatting for date columns
+ if (
+ typeof value === 'string' &&
+ (columnName.includes('date') ||
+ columnName.includes('event') ||
+ columnName === 'dob')
+ ) {
+ // Check if it's a valid date string
+ const date = new Date(value)
+ if (!isNaN(date.getTime())) {
+ return new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC',
+ }).format(date)
+ }
+ }
+
+ return String(value)
+}
+
+export function ActivistTable({ activists, visibleColumns }: ActivistTableProps) {
+ const columns = useMemo[]>(() => {
+ return visibleColumns.map((colName) => {
+ const definition = COLUMN_DEFINITIONS.find((d) => d.name === colName)
+ const label = definition?.label || colName
+
+ return {
+ id: colName,
+ header: () => (
+ {label}
+ ),
+ accessorFn: (row) => row[colName],
+ cell: ({ row }) => {
+ const value = row.original[colName]
+ return (
+
+ {formatValue(value, colName)}
+
+ )
+ },
+ }
+ })
+ }, [visibleColumns])
+
+ const table = useReactTable({
+ data: activists,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ if (activists.length === 0) {
+ return (
+
+ No activists found matching the current filters.
+
+ )
+ }
+
+ return (
+ <>
+ {/* Desktop table */}
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))}
+
+
+
+
+ {/* Mobile card layout */}
+
+ {activists.map((activist) => (
+
+
+ {visibleColumns.map((colName) => {
+ const definition = COLUMN_DEFINITIONS.find((d) => d.name === colName)
+ const label = definition?.label || colName
+ const value = activist[colName]
+
+ if (!value && value !== 0 && value !== false) return null
+
+ return (
+
+
+ {label}:
+
+
+ {formatValue(value, colName)}
+
+
+ )
+ })}
+
+
+ ))}
+
+ >
+ )
+}
diff --git a/frontend-v2/src/app/activists/column-definitions.ts b/frontend-v2/src/app/activists/column-definitions.ts
new file mode 100644
index 00000000..537c6b1d
--- /dev/null
+++ b/frontend-v2/src/app/activists/column-definitions.ts
@@ -0,0 +1,160 @@
+import { ActivistColumnName } from '@/lib/api'
+
+export type ColumnCategory =
+ | 'Basic Info'
+ | 'Location'
+ | 'Event Attendance'
+ | 'Trainings'
+ | 'Application Info'
+ | 'Circle Info'
+ | 'Prospect Info'
+ | 'Referral Info'
+ | 'Development'
+ | 'Chapter Membership'
+ | 'Other'
+
+export interface ColumnDefinition {
+ name: ActivistColumnName
+ label: string
+ category: ColumnCategory
+}
+
+export const COLUMN_DEFINITIONS: ColumnDefinition[] = [
+ // Basic Info
+ { name: 'name', label: 'Name', category: 'Basic Info' },
+ { name: 'preferred_name', label: 'Preferred Name', category: 'Basic Info' },
+ { name: 'pronouns', label: 'Pronouns', category: 'Basic Info' },
+ { name: 'email', label: 'Email', category: 'Basic Info' },
+ { name: 'phone', label: 'Phone', category: 'Basic Info' },
+ { name: 'facebook', label: 'Facebook', category: 'Basic Info' },
+ { name: 'activist_level', label: 'Level', category: 'Basic Info' },
+ { name: 'dob', label: 'Birthday', category: 'Basic Info' },
+ { name: 'accessibility', label: 'Accessibility', category: 'Basic Info' },
+ { name: 'language', label: 'Language', category: 'Basic Info' },
+
+ // Location
+ { name: 'location', label: 'Location', category: 'Location' },
+ { name: 'street_address', label: 'Street Address', category: 'Location' },
+ { name: 'city', label: 'City', category: 'Location' },
+ { name: 'state', label: 'State', category: 'Location' },
+ { name: 'lat', label: 'Latitude', category: 'Location' },
+ { name: 'lng', label: 'Longitude', category: 'Location' },
+
+ // Event Attendance
+ { name: 'total_events', label: 'Total Events', category: 'Event Attendance' },
+ { name: 'first_event', label: 'First Event', category: 'Event Attendance' },
+ { name: 'first_event_name', label: 'First Event Name', category: 'Event Attendance' },
+ { name: 'last_event', label: 'Last Event', category: 'Event Attendance' },
+ { name: 'last_event_name', label: 'Last Event Name', category: 'Event Attendance' },
+ { name: 'mpi', label: 'MPI', category: 'Event Attendance' },
+
+ // Trainings
+ { name: 'training0', label: 'Workshop', category: 'Trainings' },
+ { name: 'training1', label: 'Consent & Oppression', category: 'Trainings' },
+ { name: 'training4', label: 'Building Purposeful Communities', category: 'Trainings' },
+ { name: 'training5', label: 'Leadership & Management', category: 'Trainings' },
+ { name: 'training6', label: 'Vision and Strategy', category: 'Trainings' },
+ { name: 'training_protest', label: 'Tier 2 Protest', category: 'Trainings' },
+ { name: 'consent_quiz', label: 'Consent Refresher Quiz', category: 'Trainings' },
+
+ // Application Info
+ { name: 'dev_application_date', label: 'Applied', category: 'Application Info' },
+ { name: 'dev_application_type', label: 'Application Type', category: 'Application Info' },
+ { name: 'prospect_chapter_member', label: 'Prospective Chapter Member', category: 'Application Info' },
+ { name: 'prospect_organizer', label: 'Prospective Organizer', category: 'Application Info' },
+
+ // Circle Info
+ // Note: geo_circles not yet implemented in backend
+
+ // Prospect Info
+ { name: 'assigned_to', label: 'Assigned To', category: 'Prospect Info' },
+ { name: 'followup_date', label: 'Follow-up Date', category: 'Prospect Info' },
+ // Note: total_interactions, last_interaction_date not yet implemented in backend
+
+ // Referral Info
+ { name: 'source', label: 'Source', category: 'Referral Info' },
+ { name: 'interest_date', label: 'Interest Date', category: 'Referral Info' },
+ { name: 'referral_friends', label: 'Close Ties', category: 'Referral Info' },
+ { name: 'referral_apply', label: 'Referral', category: 'Referral Info' },
+ { name: 'referral_outlet', label: 'Referral Outlet', category: 'Referral Info' },
+
+ // Development
+ { name: 'dev_quiz', label: 'Quiz', category: 'Development' },
+ { name: 'dev_interest', label: 'Interests', category: 'Development' },
+ { name: 'connector', label: 'Coach', category: 'Development' },
+ // Note: last_connection not yet implemented in backend
+
+ // Chapter Membership
+ { name: 'cm_first_email', label: 'First Text', category: 'Chapter Membership' },
+ { name: 'cm_approval_email', label: 'Approval Email', category: 'Chapter Membership' },
+ { name: 'vision_wall', label: 'Vision Wall', category: 'Chapter Membership' },
+ { name: 'voting_agreement', label: 'Voting Agreement', category: 'Chapter Membership' },
+ { name: 'mpp_requirements', label: 'MPP Requirements', category: 'Chapter Membership' },
+
+ // Other
+ { name: 'notes', label: 'Notes', category: 'Other' },
+ { name: 'hiatus', label: 'Hiatus', category: 'Other' },
+
+ // Note: chapter_id and chapter_name are handled separately based on filter state
+]
+
+// Group columns by category
+export const groupColumnsByCategory = () => {
+ const grouped = new Map()
+
+ COLUMN_DEFINITIONS.forEach((col) => {
+ const existing = grouped.get(col.category) || []
+ grouped.set(col.category, [...existing, col])
+ })
+
+ return grouped
+}
+
+// Get default columns based on whether showing all chapters
+export const getDefaultColumns = (showAllChapters: boolean): ActivistColumnName[] => {
+ const baseColumns: ActivistColumnName[] = ['name', 'email', 'phone', 'activist_level']
+
+ if (showAllChapters) {
+ return ['chapter_name', ...baseColumns]
+ }
+
+ return baseColumns
+}
+
+// Sort columns according to their order in COLUMN_DEFINITIONS
+// Also ensures uniqueness and that 'name' is always included
+export const sortColumnsByDefinitionOrder = (
+ columns: ActivistColumnName[],
+): ActivistColumnName[] => {
+ // Deduplicate columns
+ const uniqueColumns = Array.from(new Set(columns))
+
+ // Ensure 'name' is always included
+ if (!uniqueColumns.includes('name')) {
+ uniqueColumns.push('name')
+ }
+
+ // Create a map of column name to its index in COLUMN_DEFINITIONS
+ const orderMap = new Map()
+ COLUMN_DEFINITIONS.forEach((col, index) => {
+ orderMap.set(col.name, index)
+ })
+
+ // Special handling for chapter_name (should always be first if present)
+ const hasChapterName = uniqueColumns.includes('chapter_name')
+ const columnsWithoutChapterName = uniqueColumns.filter((col) => col !== 'chapter_name')
+
+ // Sort columns based on their position in COLUMN_DEFINITIONS
+ const sorted = columnsWithoutChapterName.sort((a, b) => {
+ const orderA = orderMap.get(a) ?? Number.MAX_SAFE_INTEGER
+ const orderB = orderMap.get(b) ?? Number.MAX_SAFE_INTEGER
+ return orderA - orderB
+ })
+
+ // Add chapter_name back at the beginning if it was present
+ if (hasChapterName) {
+ return ['chapter_name', ...sorted]
+ }
+
+ return sorted
+}
diff --git a/frontend-v2/src/app/activists/column-selector.tsx b/frontend-v2/src/app/activists/column-selector.tsx
new file mode 100644
index 00000000..b380bf76
--- /dev/null
+++ b/frontend-v2/src/app/activists/column-selector.tsx
@@ -0,0 +1,170 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Columns3 } from 'lucide-react'
+import { ActivistColumnName } from '@/lib/api'
+import {
+ groupColumnsByCategory,
+ COLUMN_DEFINITIONS,
+ ColumnCategory,
+ sortColumnsByDefinitionOrder,
+} from './column-definitions'
+
+interface ColumnSelectorProps {
+ visibleColumns: ActivistColumnName[]
+ onColumnsChange: (columns: ActivistColumnName[]) => void
+ showAllChapters: boolean
+}
+
+export function ColumnSelector({
+ visibleColumns,
+ onColumnsChange,
+ showAllChapters,
+}: ColumnSelectorProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const groupedColumns = groupColumnsByCategory()
+
+ const handleToggleColumn = (columnName: ActivistColumnName) => {
+ // Prevent removing 'name' column
+ if (columnName === 'name' && visibleColumns.includes(columnName)) {
+ return
+ }
+
+ const newColumns = visibleColumns.includes(columnName)
+ ? visibleColumns.filter((col) => col !== columnName)
+ : [...visibleColumns, columnName]
+ onColumnsChange(sortColumnsByDefinitionOrder(newColumns))
+ }
+
+ const handleToggleCategory = (category: ColumnCategory) => {
+ const categoryColumns = groupedColumns.get(category) || []
+ const categoryColumnNames = categoryColumns.map((col) => col.name)
+ const allVisible = categoryColumnNames.every((col) =>
+ visibleColumns.includes(col),
+ )
+
+ let newColumns: ActivistColumnName[]
+ if (allVisible) {
+ // Remove all columns in this category
+ newColumns = visibleColumns.filter((col) => !categoryColumnNames.includes(col))
+ } else {
+ // Add all columns in this category
+ newColumns = [...visibleColumns]
+ categoryColumnNames.forEach((col) => {
+ if (!newColumns.includes(col)) {
+ newColumns.push(col)
+ }
+ })
+ }
+ onColumnsChange(sortColumnsByDefinitionOrder(newColumns))
+ }
+
+ const isCategoryFullySelected = (category: ColumnCategory) => {
+ const categoryColumns = groupedColumns.get(category) || []
+ return categoryColumns.every((col) => visibleColumns.includes(col.name))
+ }
+
+ const isCategoryPartiallySelected = (category: ColumnCategory) => {
+ const categoryColumns = groupedColumns.get(category) || []
+ const selectedCount = categoryColumns.filter((col) =>
+ visibleColumns.includes(col.name),
+ ).length
+ return selectedCount > 0 && selectedCount < categoryColumns.length
+ }
+
+ return (
+
+
+
+
+
+
+
+
Select Columns
+
+
+
+ {/* Chapter name note */}
+ {showAllChapters && (
+
+ Chapter name is automatically included when viewing all chapters
+
+ )}
+
+ {/* Column groups */}
+ {Array.from(groupedColumns.entries()).map(([category, columns]) => {
+ const fullySelected = isCategoryFullySelected(category)
+ const partiallySelected = isCategoryPartiallySelected(category)
+
+ return (
+
+
+ handleToggleCategory(category)}
+ className={
+ partiallySelected && !fullySelected
+ ? 'data-[state=checked]:bg-primary/50'
+ : ''
+ }
+ />
+
+
+
+
+ {columns.map((col) => {
+ const isNameColumn = col.name === 'name'
+ return (
+
+ handleToggleColumn(col.name)}
+ disabled={isNameColumn}
+ />
+
+
+ )
+ })}
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/frontend-v2/src/app/activists/page.tsx b/frontend-v2/src/app/activists/page.tsx
new file mode 100644
index 00000000..953c72ba
--- /dev/null
+++ b/frontend-v2/src/app/activists/page.tsx
@@ -0,0 +1,102 @@
+import {
+ dehydrate,
+ HydrationBoundary,
+ QueryClient,
+} from '@tanstack/react-query'
+import { AuthedPageLayout } from '@/app/authed-page-layout'
+import { Navbar } from '@/components/nav'
+import { ContentWrapper } from '@/app/content-wrapper'
+import {
+ API_PATH,
+ ApiClient,
+ QueryActivistOptions,
+ ActivistColumnName,
+} from '@/lib/api'
+import { getCookies } from '@/lib/auth'
+import { fetchSession } from '@/app/session'
+import ActivistsPage from './activists-page'
+import { getDefaultColumns, sortColumnsByDefinitionOrder } from './column-definitions'
+
+interface PageProps {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}
+
+export default async function ActivistsListPage({ searchParams }: PageProps) {
+ const cookies = await getCookies()
+ const apiClient = new ApiClient(cookies)
+ const queryClient = new QueryClient()
+ const session = await fetchSession(cookies)
+
+ if (!session.user) {
+ // This shouldn't happen due to AuthedPageLayout, but handle gracefully
+ return null
+ }
+
+ // Parse URL search params
+ const params = await searchParams
+ const showAllChapters = params.showAllChapters === 'true'
+ const nameSearch = typeof params.nameSearch === 'string' ? params.nameSearch : ''
+ const lastEventGte =
+ typeof params.lastEventGte === 'string' ? params.lastEventGte : undefined
+ const lastEventLt =
+ typeof params.lastEventLt === 'string' ? params.lastEventLt : undefined
+ const columnsParam = typeof params.columns === 'string' ? params.columns : ''
+
+ // Parse columns from URL or use defaults
+ const defaultColumns = getDefaultColumns(false)
+ const parsedColumns: ActivistColumnName[] = columnsParam
+ ? sortColumnsByDefinitionOrder(columnsParam.split(',') as ActivistColumnName[])
+ : defaultColumns
+
+ // Build columns list for API request
+ let columnsToRequest = [...parsedColumns]
+
+ // Add chapter_name if showing all chapters
+ if (showAllChapters && !columnsToRequest.includes('chapter_name')) {
+ columnsToRequest.unshift('chapter_name')
+ }
+
+ // Always include ID for row keys
+ if (!columnsToRequest.includes('id')) {
+ columnsToRequest.unshift('id')
+ }
+
+ // Build initial query options
+ const initialQueryOptions: QueryActivistOptions = {
+ columns: columnsToRequest,
+ filters: {
+ chapter_id: showAllChapters ? 0 : session.user.ChapterID,
+ name: nameSearch ? { name_contains: nameSearch } : undefined,
+ last_event:
+ lastEventGte || lastEventLt
+ ? {
+ last_event_gte: lastEventGte,
+ last_event_lt: lastEventLt,
+ }
+ : undefined,
+ },
+ }
+
+ // Prefetch initial activists data
+ await queryClient.prefetchQuery({
+ queryKey: [API_PATH.ACTIVISTS_SEARCH, initialQueryOptions],
+ queryFn: () => apiClient.searchActivists(initialQueryOptions),
+ })
+
+ // Prefetch chapter list (may be needed for future features)
+ await queryClient.prefetchQuery({
+ queryKey: [API_PATH.CHAPTER_LIST],
+ queryFn: apiClient.getChapterList,
+ })
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend-v2/src/lib/api.ts b/frontend-v2/src/lib/api.ts
index 057724eb..a45f4360 100644
--- a/frontend-v2/src/lib/api.ts
+++ b/frontend-v2/src/lib/api.ts
@@ -5,6 +5,7 @@ export const API_PATH = {
STATIC_RESOURCE_HASH: 'static_resources_hash',
ACTIVIST_NAMES_GET: 'activist_names/get',
ACTIVIST_LIST_BASIC: 'activist/list_basic',
+ ACTIVISTS_SEARCH: 'api/activists',
USER_ME: 'user/me',
CSRF_TOKEN: 'api/csrf-token',
CHAPTER_LIST: 'chapter/list',
@@ -95,6 +96,164 @@ export const ActivistListBasicResp = z.object({
export type ActivistListBasic = z.infer
+// Activists Search API Types
+export const ActivistColumnName = z.enum([
+ 'id',
+ 'name',
+ 'preferred_name',
+ 'email',
+ 'phone',
+ 'pronouns',
+ 'language',
+ 'accessibility',
+ 'dob',
+ 'facebook',
+ 'location',
+ 'street_address',
+ 'city',
+ 'state',
+ 'lat',
+ 'lng',
+ 'chapter_id',
+ 'chapter_name',
+ 'activist_level',
+ 'source',
+ 'hiatus',
+ 'connector',
+ 'training0',
+ 'training1',
+ 'training4',
+ 'training5',
+ 'training6',
+ 'consent_quiz',
+ 'training_protest',
+ 'dev_application_date',
+ 'dev_application_type',
+ 'dev_quiz',
+ 'dev_interest',
+ 'cm_first_email',
+ 'cm_approval_email',
+ 'prospect_organizer',
+ 'prospect_chapter_member',
+ 'referral_friends',
+ 'referral_apply',
+ 'referral_outlet',
+ 'interest_date',
+ 'mpi',
+ 'notes',
+ 'vision_wall',
+ 'mpp_requirements',
+ 'voting_agreement',
+ 'assigned_to',
+ 'followup_date',
+ 'first_event',
+ 'first_event_name',
+ 'last_event',
+ 'last_event_name',
+ 'total_events',
+])
+export type ActivistColumnName = z.infer
+
+const ActivistNameFilter = z.object({
+ name_contains: z.string().optional(),
+})
+
+const LastEventFilter = z.object({
+ last_event_lt: z.string().optional(), // ISO date string (YYYY-MM-DD)
+ last_event_gte: z.string().optional(), // ISO date string (YYYY-MM-DD)
+})
+
+const QueryActivistFilters = z.object({
+ chapter_id: z.number().optional(),
+ name: ActivistNameFilter.optional(),
+ last_event: LastEventFilter.optional(),
+ include_hidden: z.boolean().optional(),
+})
+
+const ActivistSortColumn = z.object({
+ column_name: ActivistColumnName,
+ desc: z.boolean(),
+})
+
+const ActivistSortOptions = z.object({
+ sort_columns: z.array(ActivistSortColumn),
+})
+
+export const QueryActivistOptions = z.object({
+ columns: z.array(ActivistColumnName),
+ filters: QueryActivistFilters,
+ sort: ActivistSortOptions.optional(),
+ after: z.string().optional(),
+})
+export type QueryActivistOptions = z.infer
+
+export const ActivistJSON = z.object({
+ id: z.number().optional(),
+ name: z.string().optional(),
+ preferred_name: z.string().optional(),
+ email: z.string().optional(),
+ phone: z.string().optional(),
+ pronouns: z.string().optional(),
+ language: z.string().optional(),
+ accessibility: z.string().optional(),
+ dob: z.string().optional(),
+ facebook: z.string().optional(),
+ location: z.string().optional(),
+ street_address: z.string().optional(),
+ city: z.string().optional(),
+ state: z.string().optional(),
+ lat: z.number().optional(),
+ lng: z.number().optional(),
+ chapter_id: z.number().optional(),
+ chapter_name: z.string().optional(),
+ activist_level: z.string().optional(),
+ source: z.string().optional(),
+ hiatus: z.boolean().optional(),
+ connector: z.string().optional(),
+ training0: z.string().optional(),
+ training1: z.string().optional(),
+ training4: z.string().optional(),
+ training5: z.string().optional(),
+ training6: z.string().optional(),
+ consent_quiz: z.string().optional(),
+ training_protest: z.string().optional(),
+ dev_application_date: z.string().optional(),
+ dev_application_type: z.string().optional(),
+ dev_quiz: z.string().optional(),
+ dev_interest: z.string().optional(),
+ cm_first_email: z.string().optional(),
+ cm_approval_email: z.string().optional(),
+ prospect_organizer: z.boolean().optional(),
+ prospect_chapter_member: z.boolean().optional(),
+ referral_friends: z.string().optional(),
+ referral_apply: z.string().optional(),
+ referral_outlet: z.string().optional(),
+ interest_date: z.string().optional(),
+ mpi: z.number().optional(),
+ notes: z.string().optional(),
+ vision_wall: z.string().optional(),
+ mpp_requirements: z.string().optional(),
+ voting_agreement: z.string().optional(),
+ assigned_to: z.string().optional(),
+ followup_date: z.string().optional(),
+ first_event: z.string().optional(),
+ first_event_name: z.string().optional(),
+ last_event: z.string().optional(),
+ last_event_name: z.string().optional(),
+ total_events: z.number().optional(),
+})
+export type ActivistJSON = z.infer
+
+const QueryActivistPagination = z.object({
+ next_cursor: z.string(),
+})
+
+export const QueryActivistResult = z.object({
+ activists: z.array(ActivistJSON),
+ pagination: QueryActivistPagination,
+})
+export type QueryActivistResult = z.infer
+
const EventGetResp = z.object({
event: z.object({
event_name: z.string(),
@@ -209,6 +368,17 @@ export class ApiClient {
return ActivistListBasicResp.parse(resp)
}
+ searchActivists = async (options: QueryActivistOptions) => {
+ try {
+ const resp = await this.client
+ .post(API_PATH.ACTIVISTS_SEARCH, { json: options })
+ .json()
+ return QueryActivistResult.parse(resp)
+ } catch (err) {
+ return this.handleKyError(err)
+ }
+ }
+
getChapterList = async () => {
try {
const resp = await this.client.get(API_PATH.CHAPTER_LIST).json()
diff --git a/server/src/main.go b/server/src/main.go
index 96869a76..0eafb551 100644
--- a/server/src/main.go
+++ b/server/src/main.go
@@ -215,7 +215,8 @@ func (c MainController) corsAllowGetMiddleware(h http.Handler) http.Handler {
func router() (*mux.Router, *sqlx.DB) {
db := model.NewDB(config.DBDataSource())
userRepo := persistence.NewUserRepository(db)
- main := MainController{db: db, userRepo: userRepo}
+ activistRepo := persistence.NewActivistRepository(db)
+ main := MainController{db: db, userRepo: userRepo, activistRepo: activistRepo}
csrfMiddleware := csrf.Protect(
[]byte(config.CsrfAuthKey),
csrf.Secure(config.IsProd), // disable secure flag in dev
@@ -282,6 +283,7 @@ func router() (*mux.Router, *sqlx.DB) {
// Authed API
router.Handle("/api/csrf-token", csrfMiddleware(alice.New(main.apiAttendanceAuthMiddleware).ThenFunc(main.CSRFTokenHandler))).Methods(http.MethodGet)
+ router.Handle("/api/activists", alice.New(main.apiOrganizerOrNonSFBayAuthMiddleware).ThenFunc(main.ActivistsSearchHandler)).Methods(http.MethodPost)
router.Handle("/activist_names/get", alice.New(main.apiAttendanceAuthMiddleware).ThenFunc(main.AutocompleteActivistsHandler))
router.Handle("/activist_names/get_organizers", alice.New(main.apiAttendanceAuthMiddleware).ThenFunc(main.AutocompleteOrganizersHandler))
router.Handle("/activist_names/get_chaptermembers", alice.New(main.apiAttendanceAuthMiddleware).ThenFunc(main.AutocompleteChapterMembersHandler))
@@ -356,8 +358,9 @@ func router() (*mux.Router, *sqlx.DB) {
}
type MainController struct {
- db *sqlx.DB
- userRepo model.UserRepository
+ db *sqlx.DB
+ userRepo model.UserRepository
+ activistRepo model.ActivistRepository
}
func (c MainController) authRoleMiddleware(h http.Handler, allowedRoles []string) http.Handler {
@@ -378,7 +381,7 @@ func (c MainController) authRoleMiddleware(h http.Handler, allowedRoles []string
return
}
- if !userIsAllowed(allowedRoles, user) {
+ if !model.UserHasAnyRole(allowedRoles, user) {
http.Redirect(w, r.WithContext(setUserContext(r, user)), "/403", http.StatusFound)
return
}
@@ -404,18 +407,6 @@ func (c MainController) authAdminMiddleware(h http.Handler) http.Handler {
return c.authRoleMiddleware(h, []string{"admin"})
}
-func userIsAllowed(roles []string, user model.ADBUser) bool {
- for i := 0; i < len(roles); i++ {
- for _, r := range user.Roles {
- if r == roles[i] {
- return true
- }
- }
- }
-
- return false
-}
-
func getUserMainRole(user model.ADBUser) string {
if len(user.Roles) == 0 {
return ""
@@ -454,7 +445,7 @@ func (c MainController) apiRoleMiddleware(h http.Handler, allowedRoles []string)
return
}
- if !userIsAllowed(allowedRoles, user) {
+ if !model.UserHasAnyRole(allowedRoles, user) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
@@ -1692,6 +1683,15 @@ func (c MainController) CSRFTokenHandler(w http.ResponseWriter, r *http.Request)
})
}
+func (c MainController) ActivistsSearchHandler(w http.ResponseWriter, r *http.Request) {
+ authedUser, authed := c.getAuthedADBUser(r)
+ if !authed {
+ panic("ActivistsSearchHandler requires authed ADB user")
+ }
+
+ transport.ActivistsSearchHandler(w, r, authedUser, c.activistRepo)
+}
+
func (c MainController) UsersListHandler(w http.ResponseWriter, r *http.Request) {
transport.UsersListHandler(w, r, c.userRepo)
}
diff --git a/server/src/model/activist.go b/server/src/model/activist.go
index 1c5e7cb7..1caaab4c 100644
--- a/server/src/model/activist.go
+++ b/server/src/model/activist.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"log"
+ "slices"
"regexp"
"strconv"
@@ -366,7 +367,8 @@ type Activist struct {
Accessibility string `db:"accessibility"`
Birthday sql.NullString `db:"dob"`
Coords
- ChapterID int `db:"chapter_id"`
+ ChapterID int `db:"chapter_id"`
+ ChapterName string `db:"chapter_name"`
}
type Coords struct {
@@ -388,9 +390,10 @@ type ActivistEventData struct {
}
type ActivistMembershipData struct {
- ActivistLevel string `db:"activist_level"`
- Source string `db:"source"`
- Hiatus bool `db:"hiatus"`
+ ActivistLevel string `db:"activist_level"`
+ DateOrganizer sql.NullTime `db:"date_organizer"`
+ Source string `db:"source"`
+ Hiatus bool `db:"hiatus"`
}
type ActivistConnectionData struct {
@@ -445,72 +448,73 @@ type ActivistExtra struct {
}
type ActivistJSON struct {
- Email string `json:"email"`
- Facebook string `json:"facebook"`
- ID int `json:"id"`
- Location string `json:"location"`
- Name string `json:"name"`
- PreferredName string `json:"preferred_name"`
- Phone string `json:"phone"`
- Pronouns string `json:"pronouns"`
- Language string `json:"language"`
- Accessibility string `json:"accessibility"`
- Birthday string `json:"dob"`
- ChapterID int `json:"chapter_id"`
-
- FirstEvent string `json:"first_event"`
- LastEvent string `json:"last_event"`
- FirstEventName string `json:"first_event_name"`
- LastEventName string `json:"last_event_name"`
- LastAction string `json:"last_action"`
- MonthsSinceLastAction int `json:"months_since_last_action"`
- TotalEvents int `json:"total_events"`
- TotalPoints int `json:"total_points"`
- Active bool `json:"active"`
- Status string `json:"status"`
-
- ActivistLevel string `json:"activist_level"`
- Source string `json:"source"`
- Hiatus bool `json:"hiatus"`
-
- Connector string `json:"connector"`
- Training0 string `json:"training0"`
- Training1 string `json:"training1"`
- Training4 string `json:"training4"`
- Training5 string `json:"training5"`
- Training6 string `json:"training6"`
- ConsentQuiz string `json:"consent_quiz"`
- TrainingProtest string `json:"training_protest"`
- ApplicationDate string `json:"dev_application_date"`
- ApplicationType string `json:"dev_application_type"`
- Quiz string `json:"dev_quiz"`
- DevInterest string `json:"dev_interest"`
-
- CMFirstEmail string `json:"cm_first_email"`
- CMApprovalEmail string `json:"cm_approval_email"`
- ProspectOrganizer bool `json:"prospect_organizer"`
- ProspectChapterMember bool `json:"prospect_chapter_member"`
- LastConnection string `json:"last_connection"`
- ReferralFriends string `json:"referral_friends"`
- ReferralApply string `json:"referral_apply"`
- ReferralOutlet string `json:"referral_outlet"`
- InterestDate string `json:"interest_date"`
- MPI bool `json:"mpi"`
- Notes string `json:"notes"`
- VisionWall string `json:"vision_wall"`
- MPPRequirements string `json:"mpp_requirements"`
- VotingAgreement bool `json:"voting_agreement"`
- StreetAddress string `json:"street_address"`
- City string `json:"city"`
- State string `json:"state"`
- GeoCircles string `json:"geo_circles"`
- Lat float64 `json:"lat"`
- Lng float64 `json:"lng"`
- AssignedTo int `json:"assigned_to"`
- AssignedToName string `json:"assigned_to_name"`
- FollowupDate string `json:"followup_date"`
- TotalInteractions int `json:"total_interactions"`
- LastInteractionDate string `json:"last_interaction_date"`
+ Email string `json:"email,omitempty"`
+ Facebook string `json:"facebook,omitempty"`
+ ID int `json:"id,omitempty"`
+ Location string `json:"location,omitempty"`
+ Name string `json:"name,omitempty"`
+ PreferredName string `json:"preferred_name,omitempty"`
+ Phone string `json:"phone,omitempty"`
+ Pronouns string `json:"pronouns,omitempty"`
+ Language string `json:"language,omitempty"`
+ Accessibility string `json:"accessibility,omitempty"`
+ Birthday string `json:"dob,omitempty"`
+ ChapterID int `json:"chapter_id,omitempty"`
+ ChapterName string `json:"chapter_name,omitempty"`
+
+ FirstEvent string `json:"first_event,omitempty"`
+ LastEvent string `json:"last_event,omitempty"`
+ FirstEventName string `json:"first_event_name,omitempty"`
+ LastEventName string `json:"last_event_name,omitempty"`
+ LastAction string `json:"last_action,omitempty"`
+ MonthsSinceLastAction int `json:"months_since_last_action,omitempty"`
+ TotalEvents int `json:"total_events,omitempty"`
+ TotalPoints int `json:"total_points,omitempty"`
+ Active bool `json:"active,omitempty"`
+ Status string `json:"status,omitempty"`
+
+ ActivistLevel string `json:"activist_level,omitempty"`
+ Source string `json:"source,omitempty"`
+ Hiatus bool `json:"hiatus,omitempty"`
+
+ Connector string `json:"connector,omitempty"`
+ Training0 string `json:"training0,omitempty"`
+ Training1 string `json:"training1,omitempty"`
+ Training4 string `json:"training4,omitempty"`
+ Training5 string `json:"training5,omitempty"`
+ Training6 string `json:"training6,omitempty"`
+ ConsentQuiz string `json:"consent_quiz,omitempty"`
+ TrainingProtest string `json:"training_protest,omitempty"`
+ ApplicationDate string `json:"dev_application_date,omitempty"`
+ ApplicationType string `json:"dev_application_type,omitempty"`
+ Quiz string `json:"dev_quiz,omitempty"`
+ DevInterest string `json:"dev_interest,omitempty"`
+
+ CMFirstEmail string `json:"cm_first_email,omitempty"`
+ CMApprovalEmail string `json:"cm_approval_email,omitempty"`
+ ProspectOrganizer bool `json:"prospect_organizer,omitempty"`
+ ProspectChapterMember bool `json:"prospect_chapter_member,omitempty"`
+ LastConnection string `json:"last_connection,omitempty"`
+ ReferralFriends string `json:"referral_friends,omitempty"`
+ ReferralApply string `json:"referral_apply,omitempty"`
+ ReferralOutlet string `json:"referral_outlet,omitempty"`
+ InterestDate string `json:"interest_date,omitempty"`
+ MPI bool `json:"mpi,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ VisionWall string `json:"vision_wall,omitempty"`
+ MPPRequirements string `json:"mpp_requirements,omitempty"`
+ VotingAgreement bool `json:"voting_agreement,omitempty"`
+ StreetAddress string `json:"street_address,omitempty"`
+ City string `json:"city,omitempty"`
+ State string `json:"state,omitempty"`
+ GeoCircles string `json:"geo_circles,omitempty"`
+ Lat float64 `json:"lat,omitempty"`
+ Lng float64 `json:"lng,omitempty"`
+ AssignedTo int `json:"assigned_to,omitempty"`
+ AssignedToName string `json:"assigned_to_name,omitempty"`
+ FollowupDate string `json:"followup_date,omitempty"`
+ TotalInteractions int `json:"total_interactions,omitempty"`
+ LastInteractionDate string `json:"last_interaction_date,omitempty"`
}
type GetActivistOptions struct {
@@ -575,7 +579,7 @@ func getActivistsJSON(db *sqlx.DB, options GetActivistOptions) ([]ActivistJSON,
if err != nil {
return nil, err
}
- return buildActivistJSONArray(activists), nil
+ return BuildActivistJSONArray(activists), nil
}
func GetActivistRangeJSON(db *sqlx.DB, options ActivistRangeOptionsJSON) ([]ActivistJSON, error) {
@@ -583,11 +587,12 @@ func GetActivistRangeJSON(db *sqlx.DB, options ActivistRangeOptionsJSON) ([]Acti
if err != nil {
return nil, err
}
- return buildActivistJSONArray(activists), nil
+ return BuildActivistJSONArray(activists), nil
}
-func buildActivistJSONArray(activists []ActivistExtra) []ActivistJSON {
- var activistsJSON []ActivistJSON
+// TODO: move to transport layer and make private once obsolete activist query options are removed.
+func BuildActivistJSONArray(activists []ActivistExtra) []ActivistJSON {
+ activistsJSON := []ActivistJSON{}
for _, a := range activists {
firstEvent := ""
@@ -677,6 +682,7 @@ func buildActivistJSONArray(activists []ActivistExtra) []ActivistJSON {
Facebook: a.Facebook,
ID: a.ID,
ChapterID: a.ChapterID,
+ ChapterName: a.ChapterName,
Location: location,
Name: a.Name,
PreferredName: a.PreferredName,
@@ -2226,3 +2232,130 @@ func assignActivistToUser(db *sqlx.DB, activistID, userID int) error {
}
return nil
}
+
+func QueryActivists(authedUser ADBUser, options QueryActivistOptions, repo ActivistRepository) (QueryActivistResult, error) {
+ if !UserHasRole("admin", authedUser) {
+ if authedUser.ChapterID != options.Filters.ChapterId || authedUser.ChapterID == 0 {
+ return QueryActivistResult{}, fmt.Errorf("cannot query activists in other chapters without admin access")
+ }
+ }
+
+ if !UserHasAnyRole([]string{"admin", "organizer", "non-sfbay"}, authedUser) {
+ return QueryActivistResult{}, fmt.Errorf("lacking permission to query activists")
+ }
+
+ if err := options.normalizeAndValidate(); err != nil {
+ return QueryActivistResult{}, fmt.Errorf("invalid query options: %v", err)
+ }
+
+ return repo.QueryActivists(options)
+}
+
+// Interface for querying and updating activists. This avoids a dependency on the persistence package which could create
+// a cyclical package reference.
+type ActivistRepository interface {
+ QueryActivists(options QueryActivistOptions) (QueryActivistResult, error)
+}
+
+type ActivistColumnName string
+
+type QueryActivistOptions struct {
+ // This model is currently shared with the transport layer and treated as part of the frontend API.
+ // Introduce transport DTOs when the wire format needs to differ from internal semantics.
+
+ Columns []ActivistColumnName `json:"columns"`
+ Filters QueryActivistFilters `json:"filters"`
+ Sort ActivistSortOptions `json:"sort"`
+
+ // Cursor pointing to last item in previous page (base 64 encoding of values of sort columns and ID).
+ // Must be a value returned by QueryActivistResultPagination.NextCursor.
+ // If empty, the first page of results will be returned.
+ // If invalid, an error is returned.
+ After string `json:"after"`
+}
+
+type QueryActivistFilters struct {
+ // 0 means search all chapters and requires that the "chapter" column be requested.
+ // Must be set to ID of current chapter if user only has permission for current chapter.
+ ChapterId int `json:"chapter_id"`
+ Name ActivistNameFilter `json:"name"`
+ LastEvent LastEventFilter `json:"last_event"`
+ IncludeHidden bool `json:"include_hidden"`
+}
+
+type ActivistNameFilter struct {
+ NameContains string `json:"name_contains"`
+}
+
+// DateOnly represents a date without time information (YYYY-MM-DD format).
+// The time component is always 00:00:00 UTC.
+type DateOnly struct {
+ time.Time
+}
+
+// Compile-time check that DateOnly implements json.Unmarshaler
+var _ json.Unmarshaler = (*DateOnly)(nil)
+
+// UnmarshalJSON parses a date string in YYYY-MM-DD format as UTC midnight
+func (d *DateOnly) UnmarshalJSON(data []byte) error {
+ // Remove quotes from JSON string
+ dateStr := string(data)
+ if len(dateStr) < 2 {
+ return nil
+ }
+ dateStr = dateStr[1 : len(dateStr)-1]
+
+ if dateStr == "" {
+ return nil
+ }
+
+ parsed, err := time.Parse("2006-01-02", dateStr)
+ if err != nil {
+ return fmt.Errorf("invalid date format (expected YYYY-MM-DD): %w", err)
+ }
+
+ d.Time = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
+ return nil
+}
+
+// MarshalJSON formats the date as YYYY-MM-DD
+func (d DateOnly) MarshalJSON() ([]byte, error) {
+ if d.Time.IsZero() {
+ return []byte("null"), nil
+ }
+ return []byte(`"` + d.Time.Format("2006-01-02") + `"`), nil
+}
+
+type LastEventFilter struct {
+ LastEventLt DateOnly `json:"last_event_lt"`
+ LastEventGte DateOnly `json:"last_event_gte"`
+}
+
+type ActivistSortOptions struct {
+ SortColumns []ActivistSortColumn `json:"sort_columns"`
+}
+
+type ActivistSortColumn struct {
+ ColumnName ActivistColumnName `json:"column_name"`
+ Desc bool `json:"desc"`
+}
+
+type QueryActivistResult struct {
+ Activists []ActivistExtra `json:"activists"`
+ Pagination QueryActivistResultPagination `json:"pagination"`
+}
+
+type QueryActivistResultPagination struct {
+ // An opaque string if more results are available; otherwise, the empty string.
+ NextCursor string `json:"next_cursor"`
+}
+
+func (o *QueryActivistOptions) normalizeAndValidate() error {
+ // TODO: remove invalid characters from o.nameFilter.name
+
+ if o.Filters.ChapterId == 0 && !slices.Contains(o.Columns, "chapter_name") {
+ return fmt.Errorf("must choose 'chapter_name' column when not filtering by chapter ID.")
+ }
+
+ return nil
+}
diff --git a/server/src/model/adb_auth.go b/server/src/model/adb_auth.go
index 825d570e..744786e1 100644
--- a/server/src/model/adb_auth.go
+++ b/server/src/model/adb_auth.go
@@ -54,6 +54,26 @@ func ValidateADBUser(user ADBUser) error {
return nil
}
+func UserHasAnyRole(roles []string, user ADBUser) bool {
+ for i := 0; i < len(roles); i++ {
+ if UserHasRole(roles[i], user) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func UserHasRole(role string, user ADBUser) bool {
+ for _, r := range user.Roles {
+ if r == role {
+ return true
+ }
+ }
+
+ return false
+}
+
// Interface for querying and updating users. This avoids a dependency on the persistence package which could create a
// cyclical package reference.
type UserRepository interface {
diff --git a/server/src/persistence/activist_columns.go b/server/src/persistence/activist_columns.go
new file mode 100644
index 00000000..58f22be9
--- /dev/null
+++ b/server/src/persistence/activist_columns.go
@@ -0,0 +1,119 @@
+package persistence
+
+import (
+ "fmt"
+
+ "github.com/dxe/adb/model"
+)
+
+// activistColumn defines how to select a column, including any joins it requires.
+type activistColumn struct {
+ // sql is the SQL expression for this column in the SELECT clause.
+ sql string
+ joins []joinSpec
+}
+
+var simpleColumns = map[model.ActivistColumnName]string{
+ "id": fmt.Sprintf("%s.id", activistTableAlias),
+ "name": fmt.Sprintf("%s.name", activistTableAlias),
+ "preferred_name": fmt.Sprintf("%s.preferred_name", activistTableAlias),
+ "email": fmt.Sprintf("LOWER(%s.email) as email", activistTableAlias),
+ "phone": fmt.Sprintf("%s.phone", activistTableAlias),
+ "pronouns": fmt.Sprintf("%s.pronouns", activistTableAlias),
+ "language": fmt.Sprintf("%s.language", activistTableAlias),
+ "accessibility": fmt.Sprintf("%s.accessibility", activistTableAlias),
+ "dob": fmt.Sprintf("%s.dob", activistTableAlias),
+ "facebook": fmt.Sprintf("%s.facebook", activistTableAlias),
+ "location": fmt.Sprintf("%s.location", activistTableAlias),
+ "street_address": fmt.Sprintf("%s.street_address", activistTableAlias),
+ "city": fmt.Sprintf("%s.city", activistTableAlias),
+ "state": fmt.Sprintf("%s.state", activistTableAlias),
+ "lat": fmt.Sprintf("%s.lat", activistTableAlias),
+ "lng": fmt.Sprintf("%s.lng", activistTableAlias),
+ "chapter_id": fmt.Sprintf("%s.chapter_id", activistTableAlias),
+ "activist_level": fmt.Sprintf("%s.activist_level", activistTableAlias),
+ "source": fmt.Sprintf("%s.source", activistTableAlias),
+ "hiatus": fmt.Sprintf("%s.hiatus", activistTableAlias),
+ "connector": fmt.Sprintf("%s.connector", activistTableAlias),
+ "training0": fmt.Sprintf("%s.training0", activistTableAlias),
+ "training1": fmt.Sprintf("%s.training1", activistTableAlias),
+ "training4": fmt.Sprintf("%s.training4", activistTableAlias),
+ "training5": fmt.Sprintf("%s.training5", activistTableAlias),
+ "training6": fmt.Sprintf("%s.training6", activistTableAlias),
+ "consent_quiz": fmt.Sprintf("%s.consent_quiz", activistTableAlias),
+ "training_protest": fmt.Sprintf("%s.training_protest", activistTableAlias),
+ "dev_application_date": fmt.Sprintf("%s.dev_application_date", activistTableAlias),
+ "dev_application_type": fmt.Sprintf("%s.dev_application_type", activistTableAlias),
+ "dev_quiz": fmt.Sprintf("%s.dev_quiz", activistTableAlias),
+ "dev_interest": fmt.Sprintf("%s.dev_interest", activistTableAlias),
+ "cm_first_email": fmt.Sprintf("%s.cm_first_email", activistTableAlias),
+ "cm_approval_email": fmt.Sprintf("%s.cm_approval_email", activistTableAlias),
+ "prospect_organizer": fmt.Sprintf("%s.prospect_organizer", activistTableAlias),
+ "prospect_chapter_member": fmt.Sprintf("%s.prospect_chapter_member", activistTableAlias),
+ "referral_friends": fmt.Sprintf("%s.referral_friends", activistTableAlias),
+ "referral_apply": fmt.Sprintf("%s.referral_apply", activistTableAlias),
+ "referral_outlet": fmt.Sprintf("%s.referral_outlet", activistTableAlias),
+ "interest_date": fmt.Sprintf("%s.interest_date", activistTableAlias),
+ "mpi": fmt.Sprintf("%s.mpi", activistTableAlias),
+ "notes": fmt.Sprintf("%s.notes", activistTableAlias),
+ "vision_wall": fmt.Sprintf("%s.vision_wall", activistTableAlias),
+ "mpp_requirements": fmt.Sprintf("%s.mpp_requirements", activistTableAlias),
+ "voting_agreement": fmt.Sprintf("%s.voting_agreement", activistTableAlias),
+ "assigned_to": fmt.Sprintf("%s.assigned_to", activistTableAlias),
+ "followup_date": fmt.Sprintf("DATE_FORMAT(%s.followup_date, '%%Y-%%m-%%d') as followup_date", activistTableAlias),
+}
+
+func getColumnSpec(colName model.ActivistColumnName) *activistColumn {
+ if sql, ok := simpleColumns[colName]; ok {
+ return &activistColumn{sql: sql}
+ }
+
+ switch colName {
+ case "chapter_name":
+ return &activistColumn{
+ joins: []joinSpec{chapterJoin},
+ sql: fmt.Sprintf("%s.name as chapter_name", chapterJoin.Key),
+ }
+ case "first_event":
+ // TODO: rename to first_event_date once legacy activist query is removed
+ return &activistColumn{
+ joins: []joinSpec{firstEventSubqueryJoin},
+ sql: fmt.Sprintf("%s.first_event_date as first_event", firstEventSubqueryJoin.Key),
+ }
+ case "first_event_name":
+ return &activistColumn{
+ joins: []joinSpec{firstEventSubqueryJoin},
+ sql: fmt.Sprintf("COALESCE(%s.event_name, 'n/a') as first_event_name", firstEventSubqueryJoin.Key),
+ }
+ case "last_event":
+ // TODO: rename to last_event_date once legacy activist query is removed
+ return &activistColumn{
+ joins: []joinSpec{lastEventSubqueryJoin},
+ sql: fmt.Sprintf("%s.last_event_date as last_event", lastEventSubqueryJoin.Key),
+ }
+ case "last_event_name":
+ return &activistColumn{
+ joins: []joinSpec{lastEventSubqueryJoin},
+ sql: fmt.Sprintf("COALESCE(%s.event_name, 'n/a') as last_event_name", lastEventSubqueryJoin.Key),
+ }
+ case "total_events":
+ return &activistColumn{
+ joins: []joinSpec{totalEventsSubqueryJoin},
+ sql: fmt.Sprintf("COALESCE(%s.event_count, 0) as total_events", totalEventsSubqueryJoin.Key),
+ }
+ }
+
+ // TODO: Implement these columns with proper joins:
+ // - last_action
+ // - months_since_last_action
+ // - total_points
+ // - active
+ // - status
+ // - last_connection
+ // - geo_circles
+ // - assigned_to_name
+ // - total_interactions
+ // - last_interaction_date
+
+ return nil
+}
diff --git a/server/src/persistence/activist_filters.go b/server/src/persistence/activist_filters.go
new file mode 100644
index 00000000..d7e530db
--- /dev/null
+++ b/server/src/persistence/activist_filters.go
@@ -0,0 +1,93 @@
+package persistence
+
+import (
+ "fmt"
+
+ "github.com/dxe/adb/model"
+)
+
+type filter interface {
+ buildWhere() []queryClause
+ getJoins() []joinSpec
+}
+
+// chapterFilter filters activists by chapter.
+type chapterFilter struct {
+ ChapterId int
+}
+
+func (f *chapterFilter) getJoins() []joinSpec {
+ return nil
+}
+
+func (f *chapterFilter) buildWhere() []queryClause {
+ if f.ChapterId == 0 {
+ return nil
+ }
+ return []queryClause{{
+ sql: fmt.Sprintf("%s.chapter_id = ?", activistTableAlias),
+ args: []any{f.ChapterId},
+ }}
+}
+
+// nameFilter filters activists by name using LIKE.
+type nameFilter struct {
+ NameContains string
+}
+
+func (f *nameFilter) getJoins() []joinSpec {
+ return nil
+}
+
+func (f *nameFilter) buildWhere() []queryClause {
+ if f.NameContains == "" {
+ return nil
+ }
+ return []queryClause{{
+ sql: fmt.Sprintf("%s.name LIKE ?", activistTableAlias),
+ args: []any{"%" + f.NameContains + "%"},
+ }}
+}
+
+// hiddenFilter includes or excludes hidden activists.
+type hiddenFilter struct{}
+
+func (f *hiddenFilter) getJoins() []joinSpec {
+ return nil
+}
+
+func (f *hiddenFilter) buildWhere() []queryClause {
+ return []queryClause{{
+ sql: fmt.Sprintf("%s.hidden = false", activistTableAlias),
+ args: nil,
+ }}
+}
+
+// lastEventFilter filters activists by their last event date.
+type lastEventFilter struct {
+ LastEventGte model.DateOnly
+ LastEventLt model.DateOnly
+}
+
+func (f *lastEventFilter) getJoins() []joinSpec {
+ return []joinSpec{lastEventSubqueryJoin}
+}
+
+func (f *lastEventFilter) buildWhere() []queryClause {
+ var clauses []queryClause
+
+ if !f.LastEventGte.IsZero() {
+ clauses = append(clauses, queryClause{
+ sql: fmt.Sprintf("%s.last_event_date >= ?", lastEventSubqueryJoin.Key),
+ args: []any{f.LastEventGte.Format("2006-01-02")},
+ })
+ }
+ if !f.LastEventLt.IsZero() {
+ clauses = append(clauses, queryClause{
+ sql: fmt.Sprintf("%s.last_event_date < ?", lastEventSubqueryJoin.Key),
+ args: []any{f.LastEventLt.Format("2006-01-02")},
+ })
+ }
+
+ return clauses
+}
diff --git a/server/src/persistence/activist_joins.go b/server/src/persistence/activist_joins.go
new file mode 100644
index 00000000..67893d53
--- /dev/null
+++ b/server/src/persistence/activist_joins.go
@@ -0,0 +1,97 @@
+package persistence
+
+import "fmt"
+
+// joinSpec represents a SQL join clause
+type joinSpec struct {
+ // Key is a SQL correlation name (alias of joined table in the query) as well as unique identifier to avoid
+ // performing the same join twice.
+ Key string
+ SQL string
+}
+
+// joinRegistry manages a collection of joins, ensuring each join is only added once.
+type joinRegistry struct {
+ joins map[string]string
+}
+
+func newJoinRegistry() *joinRegistry {
+ return &joinRegistry{
+ joins: make(map[string]string),
+ }
+}
+
+func (r *joinRegistry) registerJoin(spec joinSpec) {
+ if _, exists := r.joins[spec.Key]; !exists {
+ r.joins[spec.Key] = spec.SQL
+ }
+}
+
+func (r *joinRegistry) getJoins() []string {
+ joins := make([]string, 0, len(r.joins))
+ for _, sql := range r.joins {
+ joins = append(joins, sql)
+ }
+ return joins
+}
+
+const (
+ firstEventSubqueryKey = "first_event_subquery"
+ lastEventSubqueryKey = "last_event_subquery"
+ totalEventsSubqueryKey = "total_events_subquery"
+ chapterKey = "chapter"
+)
+
+var (
+ firstEventSubqueryJoin = joinSpec{
+ Key: firstEventSubqueryKey,
+ SQL: fmt.Sprintf(`
+LEFT JOIN (
+ SELECT activist_id, first_event_date, event_name
+ FROM (
+ SELECT
+ ea.activist_id,
+ e.date as first_event_date,
+ e.name as event_name,
+ ROW_NUMBER() OVER (PARTITION BY ea.activist_id ORDER BY e.date ASC) as rn
+ FROM event_attendance ea
+ JOIN events e ON e.id = ea.event_id
+ ) ranked
+ WHERE rn = 1
+) %s ON %s.activist_id = %s.id`, firstEventSubqueryKey, firstEventSubqueryKey, activistTableAlias),
+ }
+
+ lastEventSubqueryJoin = joinSpec{
+ Key: lastEventSubqueryKey,
+ // Note: a more efficient query could be used when only last_event_date is needed.
+ SQL: fmt.Sprintf(`
+LEFT JOIN (
+ SELECT activist_id, last_event_date, event_name
+ FROM (
+ SELECT
+ ea.activist_id,
+ e.date as last_event_date,
+ e.name as event_name,
+ ROW_NUMBER() OVER (PARTITION BY ea.activist_id ORDER BY e.date DESC) as rn
+ FROM event_attendance ea
+ JOIN events e ON e.id = ea.event_id
+ ) ranked
+ WHERE rn = 1
+) %s ON %s.activist_id = %s.id`, lastEventSubqueryKey, lastEventSubqueryKey, activistTableAlias),
+ }
+
+ totalEventsSubqueryJoin = joinSpec{
+ Key: totalEventsSubqueryKey,
+ SQL: fmt.Sprintf(`
+LEFT JOIN (
+ SELECT ea.activist_id, COUNT(DISTINCT ea.event_id) as event_count
+ FROM event_attendance ea
+ GROUP BY ea.activist_id
+) %s ON %s.activist_id = %s.id`, totalEventsSubqueryKey, totalEventsSubqueryKey, activistTableAlias),
+ }
+
+ chapterJoin = joinSpec{
+ Key: chapterKey,
+ SQL: fmt.Sprintf("LEFT JOIN fb_pages %s ON %s.chapter_id = %s.chapter_id", chapterKey, chapterKey, activistTableAlias),
+ }
+)
diff --git a/server/src/persistence/activists.go b/server/src/persistence/activists.go
new file mode 100644
index 00000000..23280306
--- /dev/null
+++ b/server/src/persistence/activists.go
@@ -0,0 +1,130 @@
+package persistence
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "slices"
+
+ "github.com/dxe/adb/model"
+ "github.com/jmoiron/sqlx"
+)
+
+type DBActivistRepository struct {
+ db *sqlx.DB
+}
+
+func NewActivistRepository(db *sqlx.DB) *DBActivistRepository {
+ return &DBActivistRepository{db: db}
+}
+
+const activistTableAlias = "a"
+
+type activistPaginationCursor struct {
+ // values of the last row of the previous page corresponding to the sort columns.
+ // Required for this cursor pagination implementation.
+ SortOffsetValues []any `json:"sort_values"`
+
+ // ID of the activist in the last row of the previous page.
+ IdOffset int `json:"activist_id"`
+}
+
+func (r DBActivistRepository) QueryActivists(options model.QueryActivistOptions) (model.QueryActivistResult, error) {
+ var cursor activistPaginationCursor
+ if len(options.After) > 0 {
+ decoded, err := base64.StdEncoding.DecodeString(options.After)
+ if err != nil {
+ return model.QueryActivistResult{}, fmt.Errorf("invalid pagination cursor: %w", err)
+ }
+ if err := json.Unmarshal(decoded, &cursor); err != nil {
+ return model.QueryActivistResult{}, fmt.Errorf("invalid pagination cursor: %w", err)
+ }
+ }
+ // TODO: use cursor value
+ _ = cursor
+
+ query := NewSqlQueryBuilder()
+ query.From(fmt.Sprintf("FROM activists %s", activistTableAlias))
+
+ // Convert options to filters and columns
+ filters := buildFiltersFromOptions(options)
+
+ // Ensure chapter_id is in columns if not filtering by chapter
+ columns := options.Columns
+ if options.Filters.ChapterId == 0 && !slices.Contains(columns, "chapter_id") {
+ columns = append(columns, "chapter_id")
+ }
+
+ registry := newJoinRegistry()
+
+ columnSpecs := []*activistColumn{}
+ for _, colName := range columns {
+ colSpec := getColumnSpec(colName)
+ if colSpec == nil {
+ return model.QueryActivistResult{}, fmt.Errorf("invalid column name: '%v'", colName)
+ }
+ columnSpecs = append(columnSpecs, colSpec)
+ query.SelectColumn(colSpec.sql)
+ for _, joinSpec := range colSpec.joins {
+ registry.registerJoin(joinSpec)
+ }
+ }
+
+ for _, filter := range filters {
+ for _, whereClause := range filter.buildWhere() {
+ query.Where(whereClause.sql, whereClause.args...)
+ }
+ for _, joinSpec := range filter.getJoins() {
+ registry.registerJoin(joinSpec)
+ }
+ }
+
+ for _, joinSQL := range registry.getJoins() {
+ query.Join(joinSQL)
+ }
+
+ // TODO: Apply sort options from options.Sort
+ // TODO: Increase pagination limit for prod
+ limit := 20
+ query.Limit(limit)
+
+ sqlStr, args := query.ToSQL()
+
+ activists := []model.ActivistExtra{}
+ if err := r.db.Select(&activists, sqlStr, args...); err != nil {
+ return model.QueryActivistResult{}, fmt.Errorf("querying activists: %w", err)
+ }
+
+ return model.QueryActivistResult{
+ Activists: activists,
+ Pagination: model.QueryActivistResultPagination{
+ // TODO: set NextCursor if there are more results
+ NextCursor: "",
+ },
+ }, nil
+}
+
+func buildFiltersFromOptions(options model.QueryActivistOptions) []filter {
+ var filters []filter
+
+ if options.Filters.ChapterId != 0 {
+ filters = append(filters, &chapterFilter{ChapterId: options.Filters.ChapterId})
+ }
+
+ if options.Filters.Name.NameContains != "" {
+ filters = append(filters, &nameFilter{NameContains: options.Filters.Name.NameContains})
+ }
+
+ if !options.Filters.LastEvent.LastEventLt.IsZero() || !options.Filters.LastEvent.LastEventGte.IsZero() {
+ filters = append(filters, &lastEventFilter{
+ LastEventGte: options.Filters.LastEvent.LastEventGte,
+ LastEventLt: options.Filters.LastEvent.LastEventLt,
+ })
+ }
+
+ if !options.Filters.IncludeHidden {
+ filters = append(filters, &hiddenFilter{})
+ }
+
+ return filters
+}
diff --git a/server/src/persistence/query_builder.go b/server/src/persistence/query_builder.go
new file mode 100644
index 00000000..bbce8c7f
--- /dev/null
+++ b/server/src/persistence/query_builder.go
@@ -0,0 +1,107 @@
+package persistence
+
+import (
+ "strconv"
+ "strings"
+)
+
+type sqlQueryBuilder struct {
+ base string
+ columns []string
+ joins []queryClause
+ filters []queryClause
+ orderBy []string
+ limit *int
+}
+
+type queryClause struct {
+ sql string
+ args []any
+}
+
+func NewSqlQueryBuilder() *sqlQueryBuilder {
+ return &sqlQueryBuilder{base: "FROM activists"}
+}
+
+func (b *sqlQueryBuilder) SelectColumn(column string) *sqlQueryBuilder {
+ b.columns = append(b.columns, column)
+ return b
+}
+
+func (b *sqlQueryBuilder) From(base string) *sqlQueryBuilder {
+ if strings.TrimSpace(base) != "" {
+ b.base = base
+ }
+ return b
+}
+
+func (b *sqlQueryBuilder) Join(clause string, args ...any) *sqlQueryBuilder {
+ if strings.TrimSpace(clause) != "" {
+ b.joins = append(b.joins, queryClause{sql: clause, args: args})
+ }
+ return b
+}
+
+func (b *sqlQueryBuilder) Where(clause string, args ...any) *sqlQueryBuilder {
+ if strings.TrimSpace(clause) != "" {
+ b.filters = append(b.filters, queryClause{sql: clause, args: args})
+ }
+ return b
+}
+
+func (b *sqlQueryBuilder) OrderBy(order ...string) *sqlQueryBuilder {
+ b.orderBy = append(b.orderBy, order...)
+ return b
+}
+
+func (b *sqlQueryBuilder) Limit(limit int) *sqlQueryBuilder {
+ if limit < 0 {
+ b.limit = nil
+ return b
+ }
+ b.limit = &limit
+ return b
+}
+
+func (b *sqlQueryBuilder) ToSQL() (string, []any) {
+ columns := "*"
+ if len(b.columns) > 0 {
+ columns = strings.Join(b.columns, ", ")
+ }
+
+ var builder strings.Builder
+ builder.WriteString("SELECT ")
+ builder.WriteString(columns)
+ builder.WriteString(" ")
+ builder.WriteString(b.base)
+
+ args := make([]any, 0)
+
+ for _, join := range b.joins {
+ builder.WriteString(" ")
+ builder.WriteString(join.sql)
+ args = append(args, join.args...)
+ }
+
+ if len(b.filters) > 0 {
+ builder.WriteString(" WHERE ")
+ parts := make([]string, 0, len(b.filters))
+ for _, filter := range b.filters {
+ parts = append(parts, filter.sql)
+ args = append(args, filter.args...)
+ }
+ builder.WriteString(strings.Join(parts, " AND "))
+ }
+
+ if len(b.orderBy) > 0 {
+ builder.WriteString(" ORDER BY ")
+ builder.WriteString(strings.Join(b.orderBy, ", "))
+ }
+
+ if b.limit != nil {
+ builder.WriteString(" LIMIT ")
+ builder.WriteString(strconv.Itoa(*b.limit))
+ }
+
+ return builder.String(), args
+}
diff --git a/server/src/transport/activists.go b/server/src/transport/activists.go
new file mode 100644
index 00000000..44728426
--- /dev/null
+++ b/server/src/transport/activists.go
@@ -0,0 +1,39 @@
+package transport
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+
+ "github.com/dxe/adb/model"
+)
+
+type QueryActivistResultJSON struct {
+ Activists []model.ActivistJSON `json:"activists"`
+ Pagination QueryActivistPagination `json:"pagination"`
+}
+
+type QueryActivistPagination struct {
+ NextCursor string `json:"next_cursor"`
+}
+
+func ActivistsSearchHandler(w http.ResponseWriter, r *http.Request, authedUser model.ADBUser, repo model.ActivistRepository) {
+ var options model.QueryActivistOptions
+ if err := json.NewDecoder(r.Body).Decode(&options); err != nil && err != io.EOF {
+ sendErrorMessage(w, http.StatusBadRequest, err)
+ return
+ }
+
+ result, err := model.QueryActivists(authedUser, options, repo)
+ if err != nil {
+ sendErrorMessage(w, http.StatusInternalServerError, err)
+ return
+ }
+
+ writeJSON(w, QueryActivistResultJSON{
+ Activists: model.BuildActivistJSONArray(result.Activists),
+ Pagination: QueryActivistPagination{
+ NextCursor: result.Pagination.NextCursor,
+ },
+ })
+}
diff --git a/shared/nav.json b/shared/nav.json
index cadc08e7..8551f801 100644
--- a/shared/nav.json
+++ b/shared/nav.json
@@ -141,6 +141,11 @@
"label": "Beta",
"roleRequired": ["admin"],
"items": [
+ {
+ "label": "Activists",
+ "href": "/v2/activists",
+ "page": "ActivistList"
+ },
{
"label": "New Event",
"href": "/v2/event",