Project Overview — Architecture
CivicPulse India is a pnpm monorepo: two React single-page apps (a public citizen-facing site and a separate admin console), one shared NestJS API, a background worker, a Postgres-backed data package, and this documentation site. This page describes how those pieces fit together and the design decisions behind the split.
Workspace layout
The workspace is defined in pnpm-workspace.yaml, which includes everything under apps/* and packages/*:
civicpulse/
├── apps/
│ ├── web/ # @civicpulse/web — public citizen-facing site (Vite + React, port 5173)
│ ├── admin/ # @civicpulse/admin — internal admin console (Vite + React, port 5175)
│ ├── api/ # @civicpulse/api — NestJS backend, the single shared API for both frontends
│ ├── worker/ # @civicpulse/worker — background process for jobs/sweeps (currently a minimal stub)
│ └── docs/ # @civicpulse/docs — this Docusaurus site (port 5176)
├── packages/
│ └── db/ # @civicpulse/db — Drizzle ORM schema, migrations, seed data, DB client
├── infra/
│ ├── docker-compose.yml # local Postgres + MinIO (S3-compatible object storage) for dev
│ └── postgres/ # custom Postgres image + init SQL (extensions)
└── pnpm-workspace.yaml
Each app/package is an independent pnpm workspace project with its own package.json. Cross-package
imports go through the workspace:* protocol — for example, both @civicpulse/api and
@civicpulse/worker depend on @civicpulse/db as "@civicpulse/db": "workspace:*" rather than resolving
it from the npm registry.
Root-level scripts in package.json orchestrate the workspace, including pnpm dev (runs every app's
dev script in parallel via pnpm -r --parallel run dev), per-app shortcuts (dev:web, dev:admin,
dev:api, dev:worker, dev:docs), and db:* scripts that proxy to @civicpulse/db (generate,
migrate, seed, studio, reset, bootstrap-admin).
Self-hosted, Postgres-only
CivicPulse deliberately avoids external managed services for its core data needs. Everything that would
typically be split across a relational database, a geospatial service, and a search engine instead lives
in one self-hosted Postgres instance, run via infra/docker-compose.yml.
The Postgres image is built from infra/postgres/ and boots with several extensions enabled through
infra/postgres/init.sql:
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS btree_gin;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS unaccent;
- PostGIS — geometry columns (
geometry(Point,4326)for point locations,geometry(MultiPolygon,4326)for ward/city boundaries) are defined as custom Drizzle types inpackages/db/src/schema/_types.ts, so spatial data (e.g. ward boundaries for routing an issue report to the right ward) lives in the same database as everything else rather than a separate geo service. - pg_trgm (trigram matching), btree_gin, and unaccent — support fast, fuzzy, accent-insensitive
text lookups. As of this writing, the search endpoints (
SearchService,IssuesService,ReportersService) query with Drizzle'silike()against columns such as issue title, area, ward, pincode, and reporter name/beat — this is pattern-based matching backed by these extensions rather than atsvector/to_tsqueryfull-text-search pipeline. There is no external search engine (e.g. Elasticsearch) in the stack. - pgcrypto — cryptographic primitives available at the SQL level.
- pg_cron — not created in
init.sql, because the container's init-time temporary server doesn't loadshared_preload_libraries. It's enabled separately, after the real server has booted, bypackages/db/src/migrate.ts(CREATE EXTENSION IF NOT EXISTS pg_cron, tolerating failure if unavailable). As of this writing, the extension is provisioned but nocron.schedule(...)jobs have been wired up yet; the worker app's docstring notes that scheduled sweeps (e.g. SLA-breach escalation) and aSELECT ... FOR UPDATE SKIP LOCKEDjob queue are planned future work, with pg_cron as one option and the worker's own poll loop as the fallback when pg_cron isn't available.
The Postgres container also runs on host port 5433 (not the default 5432, which the compose file notes
is already in use locally) and is backed by a named Docker volume (pgdata) for persistence.
infra/docker-compose.yml also runs MinIO, an S3-API-compatible object store, plus a one-shot
minio-init container that creates a civicpulse-media bucket and sets it to allow anonymous downloads
(explicitly dev-only). MinIO is the one non-Postgres service in local infra — it stands in for S3-compatible
media storage rather than being a Postgres extension, but it's still self-hosted rather than an external
managed service.
@civicpulse/db (packages/db) is the single source of truth for the schema. It uses
Drizzle ORM with the postgres driver, exposes a createDb() client factory
(packages/db/src/client.ts) that can open either a pooled connection (max: 10, the default, used by
request-serving code) or a direct single connection (max: 1, used for migrations and anything that
needs LISTEN/NOTIFY-style semantics), and ships CLI scripts for generating migrations
(drizzle-kit generate), applying them (migrate.ts), seeding data (seed.ts), resetting the database
(migrate.ts --reset + reseed), and bootstrapping the first admin account (bootstrap-admin.ts).
Two frontends, two auth realms
There are two separate React SPAs rather than one app with role-based views:
apps/web(@civicpulse/web) — the public citizen-facing site. It runs on Vite dev port5173and has no login flow in its source tree: it's a read-oriented surface for browsing issues, reporters, events, ward scores, and search results.apps/admin(@civicpulse/admin) — the internal admin console used by city staff. It runs on Vite dev port5175and includes a full authenticated flow (src/features/auth/LoginPage.tsx,src/features/auth/RequireAuth.tsx,src/store/AdminAuthContext.tsx) backed by an httpOnly session cookie.
They're kept as separate deployable apps — not just separate route trees in one app — because their trust boundaries are genuinely different: the admin console needs authenticated, role-gated write access to operational data, while the public site is anonymous and read-only. Keeping them as separate apps means the public bundle never ships admin routes, auth code, or role logic, and the admin console's cookie-based session never has to coexist with the public site's cookie-less request pattern in the same origin.
This split is visible directly in the API's CORS configuration (apps/api/src/main.ts). The admin
console's sessions ride on an httpOnly cookie, which requires a concrete origin allow-list with
credentials: true; a wildcard cors: true (which reflects any origin without credentials) is enough for
the public site's cookie-less reads but not for the admin console. In development, the API explicitly
allow-lists both frontend origins:
const DEV_ORIGINS = ['http://localhost:5173', 'http://localhost:5175']
Session auth on the admin side is a hand-rolled Lucia-pattern implementation (apps/api/src/admin-auth/) —
described in its own source comments as "Lucia-pattern session auth, hand-rolled rather than depending on
the lucia package." Admin accounts carry a role column typed as AdminRole in the schema
(packages/db/src/schema/admin.ts), with four roles defined: super_admin, city_admin, reviewer, and
auditor. A RolesGuard (apps/api/src/admin-auth/roles.decorator.ts) combined with a @Roles(...)
decorator gates individual controllers/handlers to specific roles — this is the API's RBAC (role-based
access control) mechanism for the admin surface. The public site, having no login, has no equivalent
role system.
The NestJS API: one backend, both frontends
apps/api (@civicpulse/api) is the single shared backend for both the public site and the admin console
— there is no separate API per frontend. It's a NestJS application
(apps/api/src/main.ts) that:
- Sets a global route prefix of
api, so every endpoint is served under/api/.... - Listens on
API_PORT(default3001). - Disables Nest's built-in body parser and installs its own
express.json()middleware with averifyhook that captures the raw request body bytes intoreq.rawBody. This is specifically so that signature-verified webhooks (the code comments call out a WhatsApp webhook) can validate an HMAC signature against the exact raw bytes received, rather than a re-serialized JSON string. - Applies
cookie-parserand the origin-restricted, credentialed CORS policy described above.
The API is organized into NestJS modules under apps/api/src/, registered in app.module.ts. Broadly they
split into two groups:
- Domain modules shared by both frontends' read paths:
db,domain,health,geography,issues,reporters,events,scores,administrators,search. - Admin-prefixed modules, used only by the admin console:
admin-auth,admin-dashboard,admin-feed,admin-integrations,admin-config,admin-geo,admin-officials,admin-reporters,admin-access,admin-notifications,admin-analytics,admin-ops.
Both frontends talk to this one API over HTTP; there's no GraphQL layer or per-frontend backend-for-frontend service — the module split (and the CORS/auth split above) is how the two audiences are separated within a single backend.
The worker app
apps/worker (@civicpulse/worker) is a standalone Node process, separate from the request-serving API,
intended for background work. As of this writing it is a minimal stub: apps/worker/src/index.ts opens a
direct (non-pooled) database connection via @civicpulse/db's createDb(undefined, 'direct'), runs
select now() to confirm connectivity, logs the result, wires up SIGINT/SIGTERM handlers for graceful
shutdown, and then idles (setInterval(() => {}, 1 << 30)) — it does not yet drain any job queue or run any
scheduled task. The file's own comments describe the intended future scope: a job drainer using
SELECT ... FOR UPDATE SKIP LOCKED over a jobs table, scheduled sweeps (such as SLA — service-level
agreement — breach escalation) for when pg_cron isn't available, and channel-ingestion handlers for
WhatsApp/Instagram/X (Twitter). None of that is implemented yet; today the worker only proves it can reach
the database and stay alive.
Summary
| Piece | What it is | Talks to |
|---|---|---|
apps/web | Public citizen site (React/Vite, port 5173, no auth) | apps/api over HTTP |
apps/admin | Admin console (React/Vite, port 5175, cookie-session auth + RBAC) | apps/api over HTTP, credentialed |
apps/api | NestJS backend, single shared API under /api prefix | Postgres via @civicpulse/db |
apps/worker | Background process (currently a connectivity-check stub) | Postgres via @civicpulse/db |
packages/db | Drizzle schema, migrations, seed scripts, DB client | Postgres (PostGIS, pg_trgm, etc.) |
apps/docs | This documentation site (Docusaurus) | — |
infra/ | Local Postgres (custom image) + MinIO via Docker Compose | — |