Skip to main content

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 in packages/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's ilike() against columns such as issue title, area, ward, pincode, and reporter name/beat — this is pattern-based matching backed by these extensions rather than a tsvector/to_tsquery full-text-search pipeline. There is no external search engine (e.g. Elasticsearch) in the stack.
  • pgcrypto — cryptographic primitives available at the SQL level.
  • pg_cronnot created in init.sql, because the container's init-time temporary server doesn't load shared_preload_libraries. It's enabled separately, after the real server has booted, by packages/db/src/migrate.ts (CREATE EXTENSION IF NOT EXISTS pg_cron, tolerating failure if unavailable). As of this writing, the extension is provisioned but no cron.schedule(...) jobs have been wired up yet; the worker app's docstring notes that scheduled sweeps (e.g. SLA-breach escalation) and a SELECT ... FOR UPDATE SKIP LOCKED job 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 port 5173 and 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 port 5175 and 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 (default 3001).
  • Disables Nest's built-in body parser and installs its own express.json() middleware with a verify hook that captures the raw request body bytes into req.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-parser and 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

PieceWhat it isTalks to
apps/webPublic citizen site (React/Vite, port 5173, no auth)apps/api over HTTP
apps/adminAdmin console (React/Vite, port 5175, cookie-session auth + RBAC)apps/api over HTTP, credentialed
apps/apiNestJS backend, single shared API under /api prefixPostgres via @civicpulse/db
apps/workerBackground process (currently a connectivity-check stub)Postgres via @civicpulse/db
packages/dbDrizzle schema, migrations, seed scripts, DB clientPostgres (PostGIS, pg_trgm, etc.)
apps/docsThis documentation site (Docusaurus)
infra/Local Postgres (custom image) + MinIO via Docker Compose