Skip to main content

API Reference — Overview

The CivicPulse backend is a single NestJS application (apps/api) that serves two distinct API surfaces from the same process: a public API consumed by the citizen-facing web app (apps/web), and an admin API consumed by the admin console (apps/admin). Both are versionless, JSON-only, and mounted under one global prefix.

Base URL and prefix

The Nest app calls app.setGlobalPrefix('api') in apps/api/src/main.ts, so every route in the system — public or admin — is reachable under /api/*. In local development the server listens on the port from the API_PORT environment variable, defaulting to 3001:

http://localhost:3001/api

main.ts also configures:

  • CORS with a concrete origin allow-list and credentials: true (via CORS_ORIGINS, falling back to http://localhost:5173 and http://localhost:5175 in dev). This is stricter than a wildcard cors: true because the admin console's session cookie needs credentials: true to be sent cross-origin — a reflect-any-origin setup without credentials wouldn't work for it.
  • A custom JSON body parser (bodyParser: false on NestFactory.create, then express.json() wired in manually) that captures the raw request body bytes onto req.rawBody. This exists so inbound webhook handlers (for example the WhatsApp webhook) can verify an HMAC signature against the exact bytes received, rather than against a re-serialized JSON string.
  • cookie-parser, used by the admin API to read the admin session cookie.

Public API vs. admin API

Public APIAdmin API
Consumed byapps/webapps/admin
Path shape/api/<resource>/api/admin/<resource>
AuthNoneCookie-based session, enforced per-controller
ExampleGET /api/issues, GET /api/issues/:idGET /api/admin/dashboard

Public API controllers (issues, reporters, events, scores, search, geography, administrators, health) declare no guards. For example, apps/api/src/issues/issues.controller.ts is a plain @Controller('issues') with GET /issues (list, filterable by cityId, category, and a free-text q param) and GET /issues/:id — no authentication is checked anywhere in the request path. These routes are meant to be read freely by the public web app.

Admin API controllers live under route prefixes like @Controller('admin/dashboard') or @Controller('admin/access'), which combine with the global api prefix to produce paths under /api/admin/*. Each such controller carries @UseGuards(AdminAuthGuard) (either on the whole controller class, or on individual handlers — see admin-auth.controller.ts, where POST /admin/auth/login is left unguarded so an admin can obtain a session, while GET /admin/auth/me and POST /admin/auth/logout require one). There is no global guard applied app-wide; each admin controller opts in explicitly.

One exception worth noting: the WhatsApp webhook controller (admin-integrations/whatsapp-webhook.controller.ts) is mounted outside the admin/ path and does not use AdminAuthGuard at all — inbound webhook calls are authenticated by HMAC signature (over the raw body captured above), not by a browser session cookie.

How AdminAuthGuard works

apps/api/src/admin-auth/admin-auth.guard.ts implements Nest's CanActivate:

  1. It reads a session token off the request's admin session cookie.
  2. If there is no cookie, it throws UnauthorizedException('Not signed in').
  3. If there is a cookie but the session doesn't validate (expired, revoked, unknown), it clears the cookie and throws UnauthorizedException('Session expired').
  4. If the session is valid and due for renewal, it re-issues the cookie with a fresh expiry.
  5. On success it attaches the resolved admin principal to req.admin for downstream handlers to read, and returns true.

There is no bearer-token or API-key mode for the admin API as of this writing — it is cookie-session only, which is why it is not usable directly from tools like curl without first completing the login flow and carrying the cookie forward.

Error shape

The API does not register a custom global exception filter or a global ValidationPipe, so errors are shaped by Nest's built-in default exception filter. Any HttpException thrown by a guard or handler — for example the UnauthorizedException thrown by AdminAuthGuard — serializes to a JSON body of the form:

{
"statusCode": 401,
"message": "Not signed in",
"error": "Unauthorized"
}

The HTTP status code on the response matches statusCode in the body. error is the standard reason phrase for that status; message is either a string (as above) or, for validation-style errors built with BadRequestException, whatever message the throwing code supplied. There is no request-tracing ID or envelope wrapper beyond this — the body is the exception's own shape, unmodified.

Where to go next

  • Public API — full route list for the unauthenticated endpoints consumed by the web app.
  • Admin API — full route list for the cookie-authenticated endpoints consumed by the admin console, including the login flow and admin role/permission model.