Project Overview — Backend API
The backend (apps/api) is a single NestJS application that serves both API surfaces described in the
Public Site and Admin Console pages: an unauthenticated
public API consumed by apps/web, and a cookie-authenticated admin API consumed by apps/admin. Both run
in one process, one AppModule, one Node server.
This page covers how that one process is organized — its module structure, the shared domain layer both surfaces call into, and how a request authenticates (or doesn't). For the full endpoint list, see the API Reference section; this page is about architecture, not route-by-route detail.
Module structure
Every module the app registers is imported into a single root module, apps/api/src/app.module.ts. That
file is the authoritative list of what's wired up — there is nothing running that isn't in this list:
imports: [
DbModule,
DomainModule,
HealthModule,
GeographyModule,
IssuesModule,
ReportersModule,
EventsModule,
ScoresModule,
AdministratorsModule,
SearchModule,
AdminAuthModule,
AdminDashboardModule,
AdminFeedModule,
AdminIntegrationsModule,
AdminConfigModule,
AdminGeoModule,
AdminOfficialsModule,
AdminReportersModule,
AdminAccessModule,
AdminNotificationsModule,
AdminAnalyticsModule,
AdminOpsModule,
]
These fall into three groups.
Infrastructure modules
DbModule(src/db/db.module.ts) — provides a single shared Drizzle ORM client (see DB connection below).DomainModule(src/domain/) — the shared business-logic layer described in the next section.
Public API modules
These are unauthenticated and correspond one-to-one with pages/features on the citizen-facing site
(apps/web):
HealthModule— a singleGET /api/healthliveness check.GeographyModule— cities and wards lookups (GET /api/cities,GET /api/wards).IssuesModule— the civic issue feed and single-issue detail (GET /api/issues,GET /api/issues/:id).ReportersModule— the public reporter directory and profile pages.EventsModule— community events and event nominations.ScoresModule— the accountability scoreboard: city summary, ward/pincode leaderboards, and the downloadable ward/pincode report.AdministratorsModule— the "know your administrators" public directory of offices (Corporator, BMC Ward Officer, MLA, MP). Despite the similar name, this is unrelated to admin console operator accounts — it lists elected/administrative offices that citizens can look up, not the internaladminstable covered under Auth model below. Don't conflate the two: "administrator" in this module means "the office your MLA holds," not "a CivicPulse operator with console access."SearchModule— the global search endpoint, which fans a query out across issues, reporters, events, and wards: it composesIssuesModule,ReportersModule, andEventsModule's services for the issues/ reporters/events results, but also queries thewardstable directly itself (there is noWardsModuleto compose) for the wards results.
Admin modules
These all sit under the /api/admin/* route prefix and are cookie-session gated (see
Auth model). Each corresponds to a section of the admin console's sidebar:
AdminAuthModule— login/logout/me, session issuance and validation, and the audit-log writer. This is the auth module itself, not a moderation surface.AdminDashboardModule— the per-city at-a-glance dashboard.AdminFeedModule— issue moderation, flagged-content review, and duplicate-cluster merging. The busiest admin module: it contains three controllers (issue-moderation,flags,duplicate-clusters) plusadmin-issue-logic.ts, an admin-specific helper that reuses the shared domain layer'sdetailOf()(see below) to compute the same chain-of-responsibility state shown to citizens.AdminIntegrationsModule— configuration and health for the multi-channel intake connections (WhatsApp, Instagram, X, the platform's own feed), outbound notification templates/delivery logs, and category/keyword routing rules. Also contains the one genuinely webhook-driven controller in the app, covered under Auth model.AdminConfigModule— policy-level configuration: feature flags, SLA/escalation timing, scoring weights, taxonomy (categories/statuses), status-transition rules, and branding.AdminGeoModule— reference-data administration for cities, wards, the pincode-to-ward map, and citizen-submitted geography corrections awaiting approval.AdminOfficialsModule— the directory of officials/offices from the operator side: editing entries, managing responsibility chains and departments, police stations, and recomputing/overriding accountability scores.AdminReportersModule— reviewing reporter applications and managing existing reporters (suspend/unsuspend).AdminAccessModule— administering admin accounts themselves: the account list, inviting new admins, activating/deactivating accounts, and a read-only role/permission matrix.AdminNotificationsModule— outbound broadcast messaging and announcements to citizens.AdminAnalyticsModule— read-only aggregate reporting (trend series, ward/official leaderboards, channel performance), computed from the same tables the rest of the app writes to.AdminOpsModule— operational surfaces: reading the audit log (and its list of distinct actors), and queue/job status.
Request flow
A typical request follows the same shape everywhere in the app: Controller → Service → Drizzle query,
with the service optionally calling into the shared domain layer for derived/computed values. For example,
IssuesController.list() delegates straight to IssuesService.list(), which builds a Drizzle query against
the issues table (with PostGIS ST_X/ST_Y extraction for coordinates) and maps each row through a
toDto() function that calls the domain layer's relativeTimeAgo() and formatCoords() helpers before
returning. Controllers hold no query logic themselves; services hold no HTTP concerns (status codes,
guards, decorators).
Nest's dependency injection wires this together: DbModule and DomainModule are both declared @Global(),
so any service in any module can @Inject(DB) the shared Drizzle client or constructor-inject
RoleContactService/ReportService without those modules needing to be re-imported everywhere.
The shared domain layer
apps/api/src/domain/ holds business logic that both the public API and the admin API call into — it has
no knowledge of HTTP, controllers, or which surface is calling it. Its index.ts re-exports everything from
a flat set of files:
| File | Contents |
|---|---|
helpers.ts | Pure utility functions: gradeOf() (numeric score → letter grade A–E), slug(), hstr() (a deterministic FNV-1a string hash used to seed reproducible numbers), pick(), phoneOf(), initialsOf(). |
constants.ts | Presentation/taxonomy constants: status color tokens (STATUS), channel metadata (CHAN), the chain-of-responsibility role templates (ROLE_DEFS: Local Police → Corporator → BMC Ward Officer → MLA → MP), category lists (CATS/CATC), ward metadata, and ward-code translation tables (WARD_SHORTCODE / WARD_SHORTCODE_REVERSE). |
issue-logic.ts | Pure issue-derivation functions: detailOf() (builds the chain-of-responsibility steps and current-owner for an issue from its status), timelineOf() (activity timeline events), galleryOf()/bigGalleryOf() (media tile layout), relativeTimeAgo(). |
role-contact.service.ts | RoleContactService — resolves the contact card (office, email, phone, score, grade) for a role+ward. Score/owned/resolved counts are always computed by the same deterministic hash regardless of data availability; jurisdiction/office/email/phone are swapped in from real seeded jurisdictions/officials rows where they exist, falling back to generated placeholder text otherwise, so every ward always renders a complete card. |
report.service.ts | ReportService — builds the full ward/pincode accountability report (score, grade, category breakdown, the Corporator → MLA → MP contact tree, and a sample of generated issues), used by ScoresModule's report/download endpoints. |
format-coords.ts | formatCoords() — lat/lng → a human-readable coordinate string. |
types.ts | The Issue interface the rest of the domain layer operates on (camelCase, matching an issues row shape). |
domain.module.ts | DomainModule — registers RoleContactService and ReportService as global providers. |
A recurring comment throughout this layer notes that much of it was ported verbatim from what was originally
client-side logic in apps/web (src/lib/helpers.ts, src/lib/logic.ts, and a static civic.ts data file),
kept byte-for-byte equivalent so the API produces the same numbers and strings the static frontend used to
compute on the client. Some of it is explicitly a placeholder: CITY_SUMMARY in constants.ts, for
instance, is a hardcoded citywide metrics object with a comment noting that real aggregation via materialized
views is a later milestone.
Both surfaces call into this layer. As of this writing, the domain modules' functions/services are
imported by public-side modules (issues, scores, search, administrators) and by admin-side modules
(admin-analytics, admin-reporters, admin-officials, admin-feed, admin-integrations's routing
service) alike. A concrete example: admin-feed's issue-moderation logic calls the same detailOf()
function from issue-logic.ts that the public issue-detail page's chain-of-responsibility view is built on
— so an admin reviewing an issue's escalation state sees it derived by the identical code path a citizen's
ticket page uses, not a parallel reimplementation.
DB connection
apps/api/src/db/db.module.ts is a @Global() module with a single provider, keyed by the injection token
DB ('DB_CONNECTION'):
{
provide: DB,
useFactory: () => createDb().db,
}
createDb() comes from the @civicpulse/db package. Every service in the app that needs to query the
database injects this same token (@Inject(DB) private readonly db: Database) rather than constructing its
own client — there is exactly one Drizzle connection shared across the whole process.
Auth model
The two API surfaces have deliberately different auth postures.
Public API: unauthenticated, read-mostly
Public controllers (issues, reporters, events, scores, administrators, search, geography,
health) declare no guards at all. IssuesController, for example, is a plain @Controller('issues') with
no @UseGuards anywhere in the file. There is no session, token, or API key checked on these routes —
they're built to be read freely by the public web app, and every one of them is a read (@Get); none of the
public modules expose a write endpoint.
Admin API: cookie-session authentication with RBAC roles defined
Admin controllers live under paths like /api/admin/dashboard or /api/admin/access, gated by @UseGuards(AdminAuthGuard)
(applied per-controller — there is no single global guard covering the whole app; each admin controller opts
in explicitly). The mechanism, in apps/api/src/admin-auth/admin-auth.service.ts and
admin-auth.guard.ts, follows the pattern popularized by the lucia auth library, but is not the lucia
npm package — it's a hand-rolled implementation of the same pattern using Node's built-in crypto and
argon2 directly:
- Passwords are hashed with
argon2(argon2.verify()on login). Login always runs a verify — even against a hardcoded dummy hash when the email doesn't match any account — specifically so response timing can't be used to distinguish "wrong password" from "no such account." - Sessions are a random 32-byte token (
randomBytes(32).toString('base64url')), handed to the client in anhttpOnlycookie (cp_admin_session,SameSite=Lax,Securein production). Only the token's SHA-256 hash is ever written to theadmin_sessionstable — a database read alone can't be replayed as a live session, since the raw token that hashes to any given row is never stored. - TTL and renewal: sessions live 12 hours (
SESSION_TTL_MS).AdminAuthGuard.canActivate()callsvalidateSessionToken()on every guarded request; if the session is valid but more than half-elapsed (pastSESSION_RENEW_THRESHOLD_MS, 6 hours), it transparently reissues the cookie with a fresh 12-hour expiry — an active admin's session slides forward without ever hitting a hard logout mid-work. An expired or unknown session gets its cookie cleared and the request rejected with401 Unauthorized. - Principal propagation: on success, the guard attaches the resolved
AdminPrincipal(id,email,name,role,cityScope) toreq.admin, which downstream handlers and services read via@Req() req: Request— there is no separate@CurrentAdmin()decorator, handlers pull it off the request object directly. - Realm separation: admin identity lives in its own
admins/admin_sessionstables, explicitly separate from any future citizen-facing auth — the comment in the schema calls this out directly.
Roles. AdminRole (defined in packages/db/src/schema/admin.ts) is one of four values: super_admin,
city_admin, reviewer, auditor. Every admin account has exactly one role, plus a cityScope column
(null = all cities, meaningful only for super_admin; otherwise an array of city IDs). The role and scope
travel on AdminPrincipal as part of the validated session, so every guarded handler has them available on
req.admin.
Nest's role-gating primitives exist in the codebase — apps/api/src/admin-auth/roles.decorator.ts defines a
@Roles(...roles: AdminRole[]) decorator plus a RolesGuard that reads that metadata and throws
ForbiddenException if the signed-in admin's role isn't in the required list. As of this writing, however,
no controller or handler in the codebase applies @Roles(), and RolesGuard is not registered anywhere —
not as a global guard, not per-controller. Every route protected by AdminAuthGuard alone is reachable by
any active, signed-in admin regardless of role: the guard verifies that the caller is a valid admin, not
which role they hold. The four-role distinction (for example, that an auditor should have read-only
access) exists in the schema and is exposed to the console's UI (a read-only "roles matrix" endpoint under
AdminAccessModule documents which role can do what), but that distinction is not yet enforced at the API
layer — it is presently a UI-level courtesy, not a server-side boundary, for anything beyond "is this a
recognized, active admin session."
The one exception: webhook auth
AdminIntegrationsModule's WhatsApp webhook controller (whatsapp-webhook.controller.ts) is mounted at
/api/whatsapp/webhook — outside the /api/admin/ prefix — and carries no AdminAuthGuard. It can't,
because the caller isn't a browser with a session cookie; it's Meta's Cloud API calling in directly. Instead
it verifies an HMAC-SHA256 signature (x-hub-signature-256 header) computed over the exact raw request
bytes, compared with timingSafeEqual, against a per-channel webhook secret stored in channel_configs —
but only when a webhook secret is actually configured: the signature-check block only runs when
config?.webhookSecret, the x-hub-signature-256 header, and req.rawBody are all present. If no secret
is configured for the channel yet, that whole block is skipped and the payload is processed completely
unverified. It's only when a secret is configured and a signature header is present but doesn't match that
the handler silently drops the payload — in every case the handler still acknowledges the webhook with
200 OK (Meta retries aggressively on non-2xx), so a caller can't distinguish "accepted" from "dropped"
from the response alone.
Audit trail
Every admin-console mutation is expected to write one immutable row via AuditService.record()
(admin-auth/audit.service.ts) into the audit_log table — actor, action, target, before/after snapshots,
and an optional note. Rows are never updated or deleted; AdminOpsModule exposes this log for reading
(GET /api/admin/ops/audit and a distinct-actors endpoint) but not for editing.