API Route Handlers
The app exposes five route handlers under src/app/api/**. They split into three buckets:
| Endpoint | Purpose |
|---|---|
POST /api/essearch | Voter / person search via Directus, with per-request credentials |
POST /api/essearch-common | Same as above but with fixed env-based credentials for the common search |
POST /api/duplicate | Per-campaign duplicate-signer check via Redis |
POST /api/charts/[id] | Generate and persist data for a configurable chart definition (super-admin / chart builder) |
POST /api/permissions/revalidate | Drop the cached permissions snapshot for the current user |
6.1 POST /api/essearch
Location: src/app/api/essearch/route.ts
Proxies the Directus-backed /essearch/validation endpoint. Token management is built in.
Request body:
{
params: {
firstName?: string; lastName?: string;
houseNumber?: string; zip?: string;
county?: string; city?: string;
order?: string;
queryFrom?: number; querySize?: number;
};
credentials: {
name: string; // Cookie name where the Directus access token is cached
emailKey: string; // Env var name holding the Directus email
endpointKey: string; // Env var name holding the Directus base URL
passwordKey: string; // Env var name holding the Directus password
};
}
emailKey / passwordKey / endpointKey are read from process.env at request time, with a fallback to the common env vars (DIRECTUS_API_EMAIL_SEARCH, DIRECTUS_API_PASSWORD_SEARCH, DIRECTUS_URL_SEARCH). Missing env → 500.
Response:
{
persons: any[];
hitsCount: number;
totalHits: number;
// or
error: string;
errorCode?: number;
}
Logic flow:
- Read params + credentials, resolve Directus env values.
- Read the cached access token from the cookie named
credentials.name. - If absent, login via
@directus/sdk(createDirectus + authentication("json") + rest()), obtain a token, set it in an HTTP-only cookie (maxAge: 3600,securein production,sameSite: strict). - Call
${directusUrl}/essearch/validationwith the token. - On
401→ delete the cookie, re-authenticate, retry once. - On
2xx→ reshape response:data.hits → persons,data.hits.length → hitsCount,data.total.value → totalHits. - On other errors → return
{ error, errorCode }.
6.2 POST /api/essearch-common
Location: src/app/api/essearch-common/route.ts
Same flow as essearch but uses fixed env vars (DIRECTUS_API_EMAIL_SEARCH, DIRECTUS_API_PASSWORD_SEARCH, DIRECTUS_URL_SEARCH) and a fixed cookie name (directus_token_search). Used by the simpler "common" voter search UI.
6.3 POST /api/duplicate
Location: src/app/api/duplicate/route.ts
Checks whether a voter has already signed for a campaign.
Request body: { campaignId: string; voterId: string }.
Response: { exists: boolean } (200) or { error } (400 / 500 / 503).
Logic:
const client = await getRedisConnection();
const exists = await client.sIsMember(`campaign:${campaignId}:signers`, voterId);
return NextResponse.json({ exists: Boolean(exists) });
If the connection drops (Connection is closed / ECONNRESET), the route resets the singleton via resetRedisConnection() and replies with 503 so the caller can retry. Sets are populated by addSignerToCampaignRedis / removeSignerFromCampaignRedis in src/lib/redis.ts and by the sync-to-redis Supabase Edge Function.
6.4 POST /api/charts/[id]
Location: src/app/api/charts/[id]/route.ts
Used by the Dashboard builder to (re)generate a chart's data.
Logic:
- Read the chart definition by
idfrom thechartstable using a service-role Supabase client (createSpecialClient(SERVICE_ROLE_KEY)). - Build a
ChartConfigfrom the row (source,groupBy,metrics,filters,campaign,team). - Run
generateChartData(config):supabase.from(config.source).select("*")filtered by team / campaign / global filters (eq,neq,gt(e),lt(e),in,contains→ilike).- Group rows by composite
groupBykey (comma-separated string, joined by|||). - For each group, compute each metric. Each metric supports an optional
segmentfilter applied per-record before aggregation. - Aggregations:
count,sum,avg,min,max.
- Persist the result on
charts.dataand return it.
Note: This endpoint uses the service role key. It must remain server-only — never import
lib/supabase/specialServer.tsfrom a client component.
6.5 POST /api/permissions/revalidate
Location: src/app/api/permissions/revalidate/route.ts
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new NextResponse(null, { status: 204 });
revalidateTag(`permissions-${user.id}`);
return new NextResponse(null, { status: 204 });
}
Drops the cached permissions snapshot for the currently signed-in user. The userId is read from the session, never from the request body, so a client cannot invalidate someone else's cache. Called by:
updateSessionon real browser refresh.src/components/common/refresh-permissions-button.tsxon the access-denied screen.
API route vs Server Action
| Feature | API Route | Server Action |
|---|---|---|
| URL | /api/... | None — direct function call |
| Use case | External services, webhooks, proxies | Form mutations / CRUD via useFormState |
| Calling | fetch('/api/...') | Imported and used as a <form> action |
| Type safety | Manual JSON typing | Full TypeScript across the boundary |
| Auth | createClient() + auth.getUser() | Same |
| Standard response | Custom JSON | FormState ({ message, error, data?, zodIssues? }) |