Skip to main content

API Route Handlers

The app exposes five route handlers under src/app/api/**. They split into three buckets:

EndpointPurpose
POST /api/essearchVoter / person search via Directus, with per-request credentials
POST /api/essearch-commonSame as above but with fixed env-based credentials for the common search
POST /api/duplicatePer-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/revalidateDrop 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:

  1. Read params + credentials, resolve Directus env values.
  2. Read the cached access token from the cookie named credentials.name.
  3. If absent, login via @directus/sdk (createDirectus + authentication("json") + rest()), obtain a token, set it in an HTTP-only cookie (maxAge: 3600, secure in production, sameSite: strict).
  4. Call ${directusUrl}/essearch/validation with the token.
  5. On 401 → delete the cookie, re-authenticate, retry once.
  6. On 2xx → reshape response: data.hits → persons, data.hits.length → hitsCount, data.total.value → totalHits.
  7. 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:

  1. Read the chart definition by id from the charts table using a service-role Supabase client (createSpecialClient(SERVICE_ROLE_KEY)).
  2. Build a ChartConfig from the row (source, groupBy, metrics, filters, campaign, team).
  3. Run generateChartData(config):
    • supabase.from(config.source).select("*") filtered by team / campaign / global filters (eq, neq, gt(e), lt(e), in, containsilike).
    • Group rows by composite groupBy key (comma-separated string, joined by |||).
    • For each group, compute each metric. Each metric supports an optional segment filter applied per-record before aggregation.
    • Aggregations: count, sum, avg, min, max.
  4. Persist the result on charts.data and return it.

Note: This endpoint uses the service role key. It must remain server-only — never import lib/supabase/specialServer.ts from 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:

  • updateSession on real browser refresh.
  • src/components/common/refresh-permissions-button.tsx on the access-denied screen.

API route vs Server Action

FeatureAPI RouteServer Action
URL/api/...None — direct function call
Use caseExternal services, webhooks, proxiesForm mutations / CRUD via useFormState
Callingfetch('/api/...')Imported and used as a <form> action
Type safetyManual JSON typingFull TypeScript across the boundary
AuthcreateClient() + auth.getUser()Same
Standard responseCustom JSONFormState ({ message, error, data?, zodIssues? })