Skip to main content

API Reference — Public API

This page documents every endpoint served by the public API — the unauthenticated, cookie-less surface consumed by the citizen-facing apps/web site. It excludes the admin-* modules (admin-auth, admin-dashboard, admin-feed, admin-integrations, admin-config, admin-geo, admin-officials, admin-reporters, admin-access, admin-notifications, admin-analytics, admin-ops), which back the internal admin console and require an authenticated session — those are covered on a separate page.

Note that the administrators module (documented below) is not an "admin" module in that sense: it's the public directory of elected officials (Corporators, MLAs, MPs) and BMC Ward Officers, distinct from the admin console's admin-* prefix.

Conventions

  • All routes are mounted under the global prefix /api (set via app.setGlobalPrefix('api') in apps/api/src/main.ts).
  • The API is a single-tenant-per-request design: nearly every list/detail endpoint takes an optional cityId query parameter that defaults to 'mumbai' if omitted. As of this writing geography (cities/wards/jurisdictions) and officials/departments/police-stations are seeded for Mumbai, Thane, and Navi Mumbai; only issues and reporters are Mumbai-only (seed.ts hardcodes cityId: MUMBAI for those two).
  • CORS is configured exactly once, app-global, in apps/api/src/main.ts — there is no per-route override anywhere. Every route in this API, public and admin alike, shares the identical strict origin allow-list with credentials: true.
  • None of the controllers in this document declare DTO validation classes (no class-validator decorated bodies) — query parameters are read directly with @Query() and are untyped strings unless otherwise noted. There is no global ValidationPipe registered in main.ts for this API.
  • Every response below is the literal shape produced by the corresponding service method, read from source. Where a value is a deterministic, hash-seeded placeholder rather than real aggregated data, that's called out explicitly — this codebase's house rule is to never present fabricated numbers as if they were real without a caveat ("honest placeholder").

Health

Module: apps/api/src/health/. A single liveness endpoint, not versioned or tenant-scoped.

GET /api/health

Returns a static liveness payload — no DB round trip:

{ "ok": true, "service": "civicpulse-api", "ts": "2026-07-09T12:00:00.000Z" }

ts is new Date().toISOString() evaluated at request time.


Geography

Module: apps/api/src/geography/. Backs the city and ward pickers.

GET /api/cities

Lists every row in the cities table, ordered by name ascending. No query parameters. Each row is the full cities schema: id (slug, e.g. "mumbai"), name, state, subdomain, status ("live" | "rollout" | "waitlist"), createdAt.

GET /api/wards?cityId=mumbai

Lists every row in the wards table for the given cityId (defaults to mumbai), ordered by code ascending. Returns the full wards schema per row: id, cityId, code, name, zone, wardNumber, officeAddress, phone, geom (nullable PostGIS multipolygon boundary — null until boundary polygons are loaded), score (nullable integer), resPct (nullable integer, only populated for the curated leaderboard set — see Scores), trend (nullable integer), createdAt.


Issues

Module: apps/api/src/issues/. The core civic-issue feed.

GET /api/issues?cityId=mumbai&category=Infrastructure&q=pothole

Lists issues for a city, newest first (createdAt desc).

  • cityId (string, optional, default "mumbai")
  • category (string, optional) — exact match against issues.category; the literal value "All" is treated as "no filter"
  • q (string, optional) — free-text search; matched with ILIKE '%term%' against title, area, ward, pincode, id, category, and reporterName (OR'd together)

Returns an array of Issue DTOs, each shaped:

{
id: string
title: string
category: string
area: string // '' if null in DB
ward: string // '' if null
wardNo: number // 0 if null
pincode: string // '' if null
mp: string // '' if null
status: string // 'new' | 'ack' | 'progress' | 'resolved' | 'overdue'
channel: string // e.g. 'Platform' | 'Instagram' | 'X' | 'WhatsApp'
votes: number
timeAgo: string // relative time, e.g. '5m' | '3h' | '2d'
reporter: string // reporterName, or 'Anonymous' if null
isCR: boolean // isCitizenReporter
coords: string | null // formatted 'lat° N, lng° E', derived from PostGIS coords
addr: string | null
media: { t: 'photo' | 'video'; d?: string }[]
desc: string // '' if null
}

coords is computed server-side from the PostGIS coords point column (ST_Y/ST_X) and formatted as e.g. "19.0596° N, 72.8295° E"; it's null if the issue has no coordinates. media entries are demo/placeholder gallery-tile descriptors, not references to real uploaded files — the schema comment on issues.media notes real uploads belong in a separate issue_media table once a write/upload path exists.

GET /api/issues/:id

Fetches a single issue by its id (e.g. CP-4821). Same Issue shape as above. Returns HTTP 404 (NotFoundException) if no issue with that id exists.


Reporters

Module: apps/api/src/reporters/. The citizen-reporter directory.

GET /api/reporters?cityId=mumbai&zone=West&q=priya

Lists reporters for a city, ordered by rank ascending.

  • cityId (string, optional, default "mumbai")
  • zone (string, optional) — exact match against reporters.zone; "All" means no filter
  • q (string, optional) — free-text search via ILIKE '%term%' against name, beat, and area (OR'd)

Returns an array of reporter DTOs:

{
name: string
slug: string
cr: boolean // isCitizenReporter
me: boolean // isMe — flags the "you" profile in demo/seed data
zone: string
beat: string | null
area: string | null
joined: string | null
reports: number
resolved: number
reached: string | null
impact: string | null
rank: number
bio: string | null
extra: ReporterReportRef[] // [{ id, title, area, pincode, status }], denormalized extra report refs
}
GET /api/reporters/:slug

Fetches a single reporter by slug. Same DTO shape as above. Returns HTTP 404 if no reporter with that slug exists.


Events

Module: apps/api/src/events/. Community events (clean-ups, plantation drives, etc.) and event nominations. Note this controller has no @Controller() path prefix of its own — routes are declared with the full path in each @Get() decorator.

GET /api/events?cityId=mumbai&type=cleanup&city=Mumbai&q=beach

Lists events for a city, ordered by date ascending.

  • cityId (string, optional, default "mumbai") — filters events.cityId
  • type (string, optional) — exact match against events.type; the literal value "all" means no filter
  • city (string, optional) — exact match against events.city (the display-name column, distinct from cityId); the literal value "All cities" means no filter
  • q (string, optional) — free-text search via ILIKE '%term%' against title, location, and city (OR'd)

Returns an array of event DTOs. For each event row, attendee rows are fetched from event_attendees and embedded:

{
id: string
title: string
desc: string // '' if null
type: string
city: string
date: string // 'YYYY-MM-DD'
time: string // '' if null
duration: string // '' if null
location: string // '' if null
maxAttendees: number
status: string // 'upcoming' | 'completed'
attendees: { name: string; avatar: string }[]
organiser: string // '' if null
impact: string // '' if null
}
GET /api/events/:id

Fetches a single event by id (e.g. EV-001). Same DTO shape as above. Returns HTTP 404 if no event with that id exists.

GET /api/nominations?cityId=mumbai

Lists community-suggested activities seeking an organiser, filtered by cityId (defaults to "mumbai"). Returns the raw nominations table rows, no transformation: id, cityId, title, desc (nullable), city, supporters (integer, default 0), type.


Scores

Module: apps/api/src/scores/. Ward/city/pincode civic scorecards. Several endpoints here mix real DB-backed scores with deterministic, hash-seeded filler numbers for fields that don't have real aggregation yet — each is called out below.

GET /api/scores/leaderboard?cityId=mumbai

Returns the curated ward leaderboard: only wards where wards.resPct IS NOT NULL (a "wards with real resPct/trend data" subset — as of this writing this is a curated set, not all wards in the city), ordered by score descending.

{
rank: number // 1-based position in the sorted list
code: string // wards.code (DB short form, e.g. 'H/W')
area: string // wards.name, falls back to code
score: number
grade: string // 'A' | 'B' | 'C' | 'D' | 'E', from gradeOf(score)
gradeC: string // hex color for the grade
rankC: string // '#FB4A2E' for rank 1, else '#CBD0D8'
res: number // resPct
barPct: string // `${resPct}%`
trendSign: string // '▲' | '▼' | '–'
trendTxt: string // absolute trend value as string, '' if 0
trendC: string // hex color keyed to trend direction
}[]
GET /api/scores/city

Returns a city-level summary. This endpoint takes no cityId parameter and is not wired to it — the controller method calls citySummary() with no arguments, and the service returns a hardcoded constant (CITY_SUMMARY in domain/constants.ts) regardless of city. The source comment on that constant states real aggregation (materialized views over the live issues table) is planned for a later milestone; today it's a placeholder so the Scores page has something to render:

{
score: number // 64
resPct: string // '58%'
reportsWeek: string // '3,420'
avgResp: string // '19h'
overdue: string // '214'
trend: string // '3'
label: string // 'Slow, but improving'
areaSampleScore: number // 76
grade: string // derived from gradeOf(score)
gradeC: string
}
GET /api/scores/units?cityId=mumbai&kind=ward

Lists scorecard units for an index page. kind is either "ward" (default) or "pincode"; any other value falls through to the ward branch.

  • When kind=ward: returns every row in wards for the city (not filtered to the curated leaderboard set), ordered by score descending — { code, area, score, grade, gradeC }[].
  • When kind=pincode: returns every seeded pincode for the city. For each pincode, the score is derived from its parent ward's score plus a small deterministic offset (hash of the pincode, range -5..+5), clamped to [32, 90], then sorted by score descending — { pincode, area, wardCode, score, grade, gradeC }[]. If a pincode has no linked ward, its base score falls back to a hardcoded 64.
GET /api/scores/report?cityId=mumbai&kind=ward&code=H%2FWest&pin=400050

Builds a full civic scorecard report for one ward or one pincode.

  • cityId (string, optional, default "mumbai")
  • kind (string, optional, default "ward")
  • code (string, optional) — ward code in the frontend's full form (e.g. "H/West"); defaults to "H/West" if omitted
  • pin (string, optional) — pincode; if kind=pincode and pin is present, the pincode branch runs, otherwise the ward branch runs regardless of kind

The real ward score is looked up from the wards table (falls back to 64 if the ward isn't found); for a pincode report the score is the ward's score plus the same deterministic ±5 offset used by /scores/units. Everything else in the report — reports, resolved, overdue, breaches, avgResp, category breakdown, and the drill-down issue list — is generated from a deterministic FNV-1a hash of the unit identifier, not queried from real issue rows. This is explicitly a ported-from-frontend placeholder (see ReportService source comments), not a real aggregation. Returned shape (CivicReport):

{
kind: string // 'Ward' | 'Pincode'
isPin: boolean
title: string // 'Ward H/West' or 'Pincode 400050'
subtitle: string
unitLabel: string
area: string
wardCode: string
wardArea: string
subdomain: string // e.g. 'mumbai.civicpulseindia.com/ward/h-west'
fileBase: string // e.g. 'civicpulse-ward-h-west'
score: number
grade: string
gradeC: string
reports: string // formatted count, e.g. '1,234' (Indian digit grouping)
reportsN: number
resolved: string
resolvedPct: string
overdue: number
breaches: number
avgResp: string // e.g. '23h'
cats: { name: string; pct: number; pctW: string; count: number; color: string }[]
tree: RoleContact[] // Corporator, MLA, MP role-contact cards
officer: RoleContact // BMC Ward Officer role-contact card
issues: GenIssue[] // 4 generated drill-down issue rows
}

GenIssue (from report.service.ts) is a lightweight, unrelated shape from the full Issue DTO used elsewhere on this page:

{
id: string
title: string
area: string
statusLabel: string
statusFg: string // hex color
statusBg: string // hex color
statusDot: string // hex color
votes: number
ago: string // relative time, e.g. '5m' | '3h' | '2d'
}

See RoleContact shape below for the tree/officer entries, and note the same "real data where seeded, deterministic placeholder otherwise" caveat applies to them.


Administrators

Module: apps/api/src/administrators/. Public directory of elected officials and ward administrative contacts — Corporators (elected municipal ward representatives), BMC (Brihanmumbai Municipal Corporation) Ward Officers, MLAs (Members of the state Legislative Assembly), and MPs (Members of Parliament, Lok Sabha). This is unrelated to the admin-* internal-console modules despite the similar name.

GET /api/administrators/roles

Returns the static list of selectable roles (no DB access):

[
{ "key": "Corporator", "label": "Corporators", "hint": "Elected · one per ward" },
{ "key": "BMC Ward Officer", "label": "Ward Officers", "hint": "BMC · administrative" },
{ "key": "MLA", "label": "MLAs", "hint": "State assembly" },
{ "key": "MP", "label": "MPs", "hint": "Lok Sabha" }
]
GET /api/administrators?role=Corporator&cityId=mumbai

Lists contact cards for a role.

  • role (string, optional, default "Corporator") — one of the keys above
  • cityId (string, optional, default "mumbai")

The result set is scoped to the same curated ward subset as the leaderboard (wards.resPct IS NOT NULL) — per the source comment, this is because the fabricated constituency-mapping data (WARD_META) needed to dedupe MLA/MP entries only covers that set. For role=Corporator or role=BMC Ward Officer, every ward in that subset returns one card. For role=MLA or role=MP, wards are deduplicated by constituency so each real MLA/MP appears once even if their constituency spans multiple wards (preferring the real constituency code from the jurisdictions table where seeded, falling back to the fictional WARD_META mapping otherwise).

Each entry is a RoleContact plus a sub label:

{
role: string
jur: string // jurisdiction label, e.g. 'Bandra West Assembly'
office: string
email: string
phone: string
score: number // deterministic hash-seeded 44-92, NOT a real performance metric
grade: string
gradeC: string
gradeBg: string
owned: number // deterministic hash-seeded case count, 60-260
resolved: number // deterministic hash-seeded, <= owned
resPct: string // resolved/owned as a percentage string
tintBg: string // avatar background color
tintFg: string // avatar foreground color
sub: string // ward name (Corporator/Ward Officer) or
// 'Assembly constituency' / 'Lok Sabha constituency' (MLA/MP)
}

Important caveat: score, owned, and resolved are always computed by a deterministic FNV-1a hash of role + code — this is true whether or not the ward has real seeded contact data, so these three fields are placeholder numbers everywhere, not real performance metrics. Only jur, office, email, and phone are swapped for real data from the jurisdictions/officials tables when a matching row exists; otherwise they fall back to generated text templates. As of this writing, real contact data is seeded for 8 of Mumbai's 24 wards plus Thane/Navi Mumbai. Note also that officials.name is intentionally never exposed by this endpoint even when a real official row exists — the schema's comment states the public API surfaces office accountability, not the individual office-holder's name.


Module: apps/api/src/search/. Cross-entity keyword search.

GET /api/search?q=pothole&cityId=mumbai
  • q (string, optional, default "") — if empty/whitespace after trimming, returns all four result arrays empty without querying the DB
  • cityId (string, optional, default "mumbai")

Fans the trimmed query out in parallel to the issues list, reporters list, and events list services (each with the same free-text ILIKE matching described in their respective sections above), plus a direct ward lookup (ILIKE on wards.code or wards.name). Returns:

{
issues: Issue[] // same shape as GET /api/issues
reporters: ReporterDto[] // same shape as GET /api/reporters
wards: { code: string; area: string; score: number; grade: string; gradeC: string }[]
events: EventDto[] // same shape as GET /api/events
}

No result is paginated or limited — all matches from each sub-query are returned.