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",