API Reference — Admin API
The admin API backs the CivicPulse admin console — the internal operator UI used by city staff to moderate reports, configure the platform, and manage officials/reporters/access. Unlike the public API, every route here is authenticated with an httpOnly session cookie rather than a bearer token, and most mutating routes write an entry to an immutable audit log.
This page documents every controller found under an admin-* source folder in apps/api/src/ (12 modules,
matching the directories in the repo), including the auth module itself. All paths are shown with the
API's global prefix, so a route declared as @Controller('admin/feed/issues') is reachable at
/api/admin/feed/issues.
Conventions used on this page
Base path. All routes below are relative to the API origin, e.g. http://localhost:3001/api/admin/...
in local development.
Session auth. Every controller in this document is decorated with @UseGuards(AdminAuthGuard) at the
class level — a request without a valid session cookie gets a 401 Unauthorized — with exactly two
exceptions: the WhatsApp webhook controller (documented at the end of the Integrations section), which is a
public inbound webhook for Meta's platform, not an admin-facing route (it is not mounted under /api/admin
and does not require the session cookie); and admin-auth.controller.ts (see the Auth section below), which
applies AdminAuthGuard per-method rather than at the class level — me and logout are guarded
individually, while login carries no guard at all, since that route is what establishes the session in the
first place.
The session cookie is named cp_admin_session (httpOnly, sameSite=lax, secure in production). A
session lasts 12 hours from issue and slides forward (a fresh 12-hour expiry is written back) whenever a
request arrives with less than 6 hours remaining, so an active admin is never logged out mid-session. The
raw token lives only in the cookie; the database stores just its SHA-256 hash, so a database read alone
can't be replayed as a live session. Login runs the password check (argon2) against a fixed dummy hash even
when the account doesn't exist, so response timing can't be used to enumerate valid admin emails. Because
the cookie carries credentials, the API's CORS configuration allow-lists specific origins and sets
credentials: true (see apps/api/src/main.ts) rather than reflecting an arbitrary origin.
Roles. Every admin account has one role: super_admin, city_admin, reviewer, or auditor
(AdminRole, defined in packages/db/src/schema/admin.ts), plus an optional cityScope (an array of city
IDs the account is restricted to; null means unrestricted — implicitly all cities, since today only
Mumbai has real data). The admin-auth module also defines a @Roles(...) decorator and a RolesGuard for
declaratively restricting a route to specific roles (apps/api/src/admin-auth/roles.decorator.ts), and
admin-access.controller.ts exposes a static roles/permissions matrix describing what each role is
intended to be able to do. As of this writing, however, @Roles/RolesGuard is not attached to any
controller in the codebase — session authentication (any active admin) is the only guard actually enforced
on these routes, with one exception: the "invite admin" endpoint checks actor.role === 'super_admin'
directly in its service method and throws 403 Forbidden otherwise. Don't rely on the roles matrix as a
description of enforced behavior; treat it as a design reference until RolesGuard is wired up.
Mutations and the audit log. AuditService.record(...) writes one immutable row to the shared
audit_log table for most state-changing actions, capturing the actor, an action string (e.g.
issue.status_change), a targetType/targetId, and optional before/after JSON snapshots. Below,
every mutating endpoint is marked with Mutation: yes and its audit action string if — and only if —
the service method backing it actually calls AuditService.record(...); a small number of state-changing
endpoints do not audit-log (e.g. login/logout, and a couple of "no-op if already resolved" early returns),
and those are called out explicitly rather than assumed.
"Honest placeholder." A recurring convention in this codebase: where a metric or list depends on a
subsystem that doesn't exist yet (e.g. an SLA-timer sweep job, a citizen-accounts table), the API returns an
explicit placeholder (a placeholder: true flag, a note explaining what's missing, or a genuinely empty
array) rather than a fabricated but plausible-looking number. This is called out inline wherever it applies.
Response shapes. Where a service method reshapes rows into a specific view-model, that exact shape is
documented. Where a service method returns raw Drizzle rows unmodified (this.db.select().from(table)),
this page says so and names the backing table — see the schema files under packages/db/src/schema/ for
the full column list of that table.
Auth
Directory: apps/api/src/admin-auth/ — controller: admin-auth.controller.ts, base path /api/admin/auth.
This is the one controller whose routes are not uniformly guarded: login is intentionally open (it's how
a session is obtained), while me and logout require an existing session.
Log in
POST /api/admin/auth/login
Verifies an admin's email/password and issues a session cookie.
- Auth: none required (this is the entry point)
- Mutation: yes, but not audit-logged —
AdminAuthService.login()inserts a session row and updatesadmins.lastLoginAt, but never callsAuditService.record(...). - Body:
{ email: string; password: string }— both required, or400 Bad Request. - Response:
{ admin: AdminPrincipal }whereAdminPrincipal = { id, email, name, role: AdminRole, cityScope: string[] | null }. Sets thecp_admin_sessioncookie. Throws401 Unauthorized("Invalid email or password") on a bad credential or a deactivated account (admins.active = false).
Get current admin
GET /api/admin/auth/me
Returns the session's admin principal from req.admin (populated by AdminAuthGuard) without hitting the
database.
- Mutation: no.
- Response:
{ admin: AdminPrincipal }.
Log out
POST /api/admin/auth/logout
Invalidates the current session.
- Mutation: yes, but not audit-logged —
invalidateSession()deletes theadmin_sessionsrow and the controller clears the cookie; noAuditServicecall. - Response:
{ ok: true }. Clears thecp_admin_sessioncookie.
Dashboard
Directory: apps/api/src/admin-dashboard/ — controller: admin-dashboard.controller.ts, base path
/api/admin/dashboard.
Get dashboard
GET /api/admin/dashboard
Returns the console's landing-page KPIs and a 14-day report/resolution trend for one city.
- Mutation: no.
- Query:
cityId(string, optional, defaults tomumbai). - Response:
Real (non-placeholder) KPIs today: open issues, overdue/SLA-breached, resolution rate, reports today, reports this week — all computed directly from the{kpis: {key: stringlabel: stringvalue: string // e.g. "42", "—" for a placeholderdelta: string | nullgood: boolean | nullplaceholder: boolean // true = no backing subsystem yet, see "honest placeholder" above}[]series: { day: string; reported: number; resolved: number }[] // 14 entries, oldest first, zero-filledneedsAttention: [] // always empty today — see below}
issuestable. Placeholder KPIs (placeholder: true, value"—"): escalations armed, avg first response, intake backlog, pending applications, open flags — each depends on a subsystem (SLA-timer automation, an intake pipeline) not yet built.needsAttentionis always[]: the design mockup this screen is based on ships illustrative entries here, but this service deliberately returns a real, honest empty list instead of reusing them.
Feed Review
Directory: apps/api/src/admin-feed/ — three controllers, sharing the module AdminFeedModule.
Issues
Controller: issue-moderation.controller.ts, base path /api/admin/feed/issues.
Issue moderation state (force-escalated, SLA-paused, closed, hidden, pinned, chain-role override, redacted
media indices) lives in a separate issue_admin_state table — a one-row-per-issue overlay created lazily on
first moderation action — rather than mutating the append-only issues/issue_status_events tables the
public site reads, so the public accountability trail stays untouched by admin actions other than an
explicit status change.
List issues
GET /api/admin/feed/issues
- Mutation: no.
- Query:
cityId(defaultmumbai),status(issue status, orall/omitted for no filter),q(free-text search against the issue title,ILIKE %q%). - Response: array of
{ id, title, category, ward, pincode, status, statusLabel, pillBg, pillFg, pillDot, slaLabel, slaColor, role, forced, slaPaused, closed, hidden, pinned }.slaLabelis computed fromsla_config.window_hours(default 48h) and the issue's real elapsed time — see the note below onstatusvs. the SLA clock.
Get issue detail
GET /api/admin/feed/issues/:id
- Mutation: no. Throws
404 Not Foundif the issue doesn't exist. - Response:
{ id, title, metaLine, status, chain, activity: {t, when}[], mediaTiles: {index, kind, redacted}[], forced, slaPaused, closed, hidden, pinned, chainRoleOverride }.chainis the 5-step responsibility chain (Local Police → Corporator → BMC Ward Officer → MLA → MP), adjusted for anychainRoleOverrideset via the Reassign action below.
Change status
POST /api/admin/feed/issues/:id/status
- Mutation: yes — audit action
issue.status_change. - Body:
{ status: string }(required,400if missing). - Validates the transition against the
status_transitionstable (configurable in Configuration → Status transitions, see below) rather than a hardcoded matrix; throws400 Bad Requestif the transition isn't allowed. Also appends a row toissue_status_events. - Response:
{ status: string }.
The stored status is treated as the authoritative narrative — there's no SLA-sweep cron job yet keeping it
in perfect lockstep with real elapsed time, so a seeded issue can display as "overdue" before the SLA window
has actually elapsed in wall-clock time. slaLabel's sign always follows the stored status; only its
displayed magnitude is derived from the real clock (and clamped to stay positive), so it never reads as
self-contradictory.
Force-escalate / pause SLA / close / hide / pin (toggles)
POST /api/admin/feed/issues/:id/force-escalate
POST /api/admin/feed/issues/:id/pause-sla
POST /api/admin/feed/issues/:id/toggle-closed
POST /api/admin/feed/issues/:id/toggle-hidden
POST /api/admin/feed/issues/:id/toggle-pinned
Each flips the corresponding boolean column on issue_admin_state (forced, slaPaused, closed,
hidden, pinned).
- Mutation: yes for all five — audit actions
issue.force_escalate,issue.toggle_pause_sla,issue.toggle_closed,issue.toggle_hidden,issue.toggle_pinnedrespectively. - Body: none.
- Response: the full updated
issue_admin_staterow.
Reassign
POST /api/admin/feed/issues/:id/reassign
Advances chainRoleOverride to the next step in the fixed 5-role chain (null → Local Police →
Corporator → BMC Ward Officer → MLA → MP → back to null, i.e. "defer to the default chain").
- Mutation: yes — audit action
issue.reassign. - Response: the full updated
issue_admin_staterow.
Redact media
POST /api/admin/feed/issues/:id/redact-media
Toggles whether a specific media item on the issue is marked redacted (face/plate/phone masked in the UI).
- Mutation: yes — audit action
issue.toggle_redact_media. - Body:
{ index: number }(required — the index into the issue's media array;400if not a number). - Response: the full updated
issue_admin_staterow.
Flags
Controller: flags.controller.ts, base path /api/admin/feed/flags.
User-reported content (issue, comment, media, or reporter profile) awaiting moderation review.
List flags
GET /api/admin/feed/flags
- Mutation: no.
- Query:
reason— one ofspam,abuse,wronginfo,duplicate,other, orall/omitted. - Response: array of
{ id, reasonLabel, target, snippet, reporterCount, resolved, resolvedLabel, showBan }.showBanistrueonly when the flag's target is a reporter profile (banning is offered for people, not content).
Dismiss / warn / remove / ban
POST /api/admin/feed/flags/:id/dismiss
POST /api/admin/feed/flags/:id/warn
POST /api/admin/feed/flags/:id/remove
POST /api/admin/feed/flags/:id/ban
Each sets the flag's status (dismissed | warned | removed | banned) and stamps
resolvedBy/resolvedAt with the acting admin.
- Mutation: yes for all four — audit actions
flag.dismiss,flag.warn,flag.remove,flag.ban. Throws404 Not Foundif the flag doesn't exist. - Response:
{ id, status }.
Duplicate clusters
Controller: duplicate-clusters.controller.ts, base path /api/admin/feed/duplicates.
Near-duplicate issue reports (same location/category/time window) grouped for a moderator to merge.
List clusters
GET /api/admin/feed/duplicates
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: array of
{ id, category, ward, window, members: string[], merged: boolean }.
Merge
POST /api/admin/feed/duplicates/:id/merge
Marks the cluster merged and sets canonicalIssueId to the first member issue ID.
- Mutation: yes — audit action
duplicate_cluster.merge. Exception: if the cluster is already merged, the handler returns immediately ({ id, merged: true }) without touching the database or writing an audit row. - Response:
{ id, merged: true, canonicalIssueId }.
Integrations
Directory: apps/api/src/admin-integrations/ — five controllers under AdminIntegrationsModule, plus
per-channel adapters in adapters/ (X, Instagram, WhatsApp) that each report whether a call ran in live
or demo mode.
Channel configuration
Controller: channel-config.controller.ts, base path /api/admin/integrations/channels.
List channels
GET /api/admin/integrations/channels
- Mutation: no.
- Response: array of
{ channel, name, color, demoMode, connectionStatus, handle, hasSecret, secretMasked, webhookVerifyToken, webhookUrl, lastReceived, lastCheckedAt, lastError, errorRate, requestCount }for each row inchannel_configs(channelis one ofwhatsapp,instagram,x,platform).connectionStatusreports"demo"wheneverdemoModeis on, regardless of the stored connection state — it doesn't claim real connectivity until live credentials exist.errorRateis computed live from the realerrorCount/requestCountcounters, not a fabricated static figure.
Test connection
POST /api/admin/integrations/channels/:channel/test
Makes a real (adapter-mediated) lightweight call to the channel and records whether it succeeded.
- Mutation: yes for
x/instagram/whatsapp— audit actionchannel.test_connection(recorded on both the success and failure path). Forchannel = 'platform', the handler short-circuits and returns immediately without touching the database or the audit log, since the platform's own channel is always considered connected. - Response:
{ channel, ok: boolean, mode: 'live' | 'demo', message: string }. On failure, updateschannel_configs.connectionStatusto'degraded'and records the error message.
Toggle demo mode
POST /api/admin/integrations/channels/:channel/toggle-demo
- Mutation: yes — audit action
channel.toggle_demo_mode. Throws404 Not Foundfor an unknown channel key. - Response:
{ channel, demoMode: boolean }.
Channel mentions
Controller: channel-mentions.controller.ts, base path /api/admin/integrations/mentions.
List mentions
GET /api/admin/integrations/mentions
- Mutation: no.
- Response: the 50 most recent
channel_mentionsrows, shaped as{ id, chanLabel, from, text, time, converted: boolean, convertedIssueId }.
Sync now
POST /api/admin/integrations/mentions/sync/:channel
Pulls fresh mentions through the channel's adapter and inserts any not already stored (deduped by
channel + externalId).
- Mutation: yes — audit action
channel.sync_mentions. - Params:
channel— one ofx,instagram,whatsapp. - Response:
{ fetched: number, inserted: number, mode: 'live' | 'demo' }.
Convert to issue
POST /api/admin/integrations/mentions/:id/convert
Creates a new issues row from a mention's text (auto-detecting category via keyword matching), plus an
initial issue_status_events entry.
- Mutation: yes — audit action
channel_mention.convert_to_issue. Exception: if the mention was already converted, returns immediately ({ issueId, alreadyConverted: true }) without writing a new issue or an audit row. - Response:
{ issueId: string, alreadyConverted: boolean }.
A converted issue is deliberately left without a ward/pincode — a channel mention only carries free-text city detection, not a real GPS coordinate or pincode, so guessing a ward would fabricate data the mention doesn't actually contain. A moderator sets routing manually once an intake-triage affordance exists.
Outbound messaging
Controller: outbound.controller.ts, base path /api/admin/integrations/outbound.
Templates
GET /api/admin/integrations/outbound/templates
- Mutation: no. Response: active rows from
message_templates({ key, name, body, active }).
Delivery logs
GET /api/admin/integrations/outbound/delivery-logs
- Mutation: no. Response: the 50 most recent
delivery_logsrows ({ id, channel, toMasked, templateKey, status: 'sent' | 'failed' | 'retrying', createdAt }).
Get send limits
GET /api/admin/integrations/outbound/send-limits
- Mutation: no. Response: the singleton
outbound_configrow —{ id: 'default', quietFrom, quietTo, rateCapPerHour }— or those same defaults ("22:00"/"08:00"/3) if the row hasn't been created yet.
Update send limits
POST /api/admin/integrations/outbound/send-limits
- Mutation: yes — audit action
outbound.update_send_limits. - Body:
{ quietFrom?: string; quietTo?: string; rateCapPerHour?: number }— partial patch, upserted into thedefaultrow. - Response: the updated
outbound_configrow.
Routing
Controller: routing.controller.ts, base path /api/admin/integrations/routing.
Category → department map
GET /api/admin/integrations/routing/category-map
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: one entry per category in
{ category, office }[], whereofficeis the resolved department name (or"Unassigned"), via a fixed crosswalk from this project's 6 categories (Infrastructure, Garbage & filth, Safety, Traffic, Public transport, Corruption) to the department taxonomy seeded from the legacy accountability data (road/traffic/infrastructure/hygiene/ healthcare/systemic). Safety and Corruption both fall back to the general-administration department since no dedicated seeded department matches them; Public transport falls back to thealldepartment since nothing covers BEST/transit specifically.
Keyword taxonomy
GET /api/admin/integrations/routing/keywords
- Mutation: no.
- Response:
{ category, keywords: { id, keyword }[] }[], one entry per category (including empty ones), fromrouting_keywords.
Add keyword
POST /api/admin/integrations/routing/keywords
- Mutation: yes — audit action
routing.add_keyword. - Body:
{ category: string; keyword: string }(both required). Note: the controller currently validates missing fields by throwing a plainError, not aBadRequestException, so a missingcategory/keywordsurfaces as a500rather than a400as of this writing. - Response: the inserted
routing_keywordsrow.
Remove keyword
DELETE /api/admin/integrations/routing/keywords/:id
- Mutation: yes — audit action
routing.remove_keyword. - Response:
{ id: string }.
City-detection rules
GET /api/admin/integrations/routing/city-detection
- Mutation: no.
- Response: array of
{ rule: string; target: string }— one row per city summarizing how many pincodes are mapped to full ward/city routing for it, plus a fixed trailing entry noting that a GPS bounding-box fallback is "Not yet implemented — pending ward boundary (GeoJSON) upload."
WhatsApp webhook (not admin-session authenticated)
Controller: whatsapp-webhook.controller.ts, base path /api/whatsapp/webhook.
Unlike every other controller on this page, this one is not guarded by AdminAuthGuard and is not
mounted under /api/admin. It's the one genuinely webhook-driven channel (Meta's WhatsApp Cloud API) — Meta
calls it directly, so it's authenticated by Meta's own HMAC signature scheme instead of a session cookie.
It's documented here because it lives in the same admin-integrations source folder and module.
Verify webhook subscription
GET /api/whatsapp/webhook
Meta's webhook-verification handshake.
- Query:
hub.mode,hub.verify_token,hub.challenge. - If
hub.mode === 'subscribe'andhub.verify_tokenmatches the fixed dev tokencivicpulse_wa_verify(there's no per-channel-config verify token wired into this check yet), responds with the rawhub.challengestring. Otherwise400 Bad Request.
Receive messages
POST /api/whatsapp/webhook
- Responds
200 { ok: true }immediately (Meta requires an ack within 5 seconds and retries aggressively otherwise), then processes asynchronously. - If
channel_configs.whatsapp.webhookSecretis set and anX-Hub-Signature-256header is present, verifies the HMAC-SHA256 signature against the raw request body (not a re-serialized JSON string) using a constant-time comparison; an invalid signature is dropped silently post-ack. - Each new inbound message (deduped by WhatsApp message ID) is inserted into
channel_mentionswith the sender's phone number masked (e.g.+91 987XX X3210) and a keyword-detected category/city hint. - Mutation: yes (inserts to
channel_mentions, updateschannel_configs.lastReceivedAt), but this path has noAdminPrincipalactor and does not write to the admin audit log — there's no admin involved in an inbound webhook delivery.
Configuration
Directory: apps/api/src/admin-config/ — controller: configuration.controller.ts, base path
/api/admin/config.
Three of this module's write endpoints — SLA, scoring, and branding — require a non-empty note field in
the request body and throw 400 Bad Request ("An audit note is required to save configuration changes.")
if it's missing or blank. This is enforced in ConfigurationService via a shared requireNote() helper.
The feature-flag and taxonomy write endpoints do not enforce this.
Feature flags
GET /api/admin/config/flags
- Mutation: no. Response: all rows from
feature_flags({ key, label, description, cityId, enabled }).
POST /api/admin/config/flags/:key
- Mutation: yes — audit action
config.feature_flag.update. Nonoterequirement on this endpoint. - Body:
{ enabled: boolean; note?: string }. - Response:
{ key, enabled }.
Feature flags are stored and audited here but, as of this writing, are not yet consumed anywhere else in the app to actually gate behavior — flipping one is recorded faithfully but has no runtime effect yet.
SLA & escalation config
GET /api/admin/config/sla
- Mutation: no. Response: the singleton
sla_configrow:{ id: 'default', windowHours, chainTimers: Record<string, number>, autoEscalate, holidays: string[], notifyOnAck, notifyOnResolve, notifyOnAssign, notifyOnEscalate }.windowHours(default 48) is the one field genuinely read elsewhere — Feed Review's SLA-label computation reads it instead of a hardcoded constant.
POST /api/admin/config/sla
- Mutation: yes — audit action
config.sla.update. Requires a non-emptynote(see above). - Body: a partial patch of the
sla_configcolumns above, plusnote: string. - Response: the updated
sla_configrow.
Scoring config
GET /api/admin/config/scoring
- Mutation: no. Response: the singleton
scoring_configrow:{ id: 'default', weightVolume, weightSpeed, weightResolution, weightRecurrence, gradeA, gradeB, gradeC, gradeD, refreshCadence }.
POST /api/admin/config/scoring
- Mutation: yes — audit action
config.scoring.update. Requires a non-emptynote. - Body: a partial patch of the columns above, plus
note: string. - Response: the updated
scoring_configrow.
Scoring weights/thresholds are stored and editable here, but the live gradeOf() scoring algorithm used
elsewhere in the app is still hardcoded — same "stored but not yet wired up" status as feature flags.
Taxonomy lists
GET /api/admin/config/taxonomy
- Mutation: no.
- Query:
list— one ofcategories,priorities,flagReasons,eventTypes(TaxonomyListKey). - Response: the matching
taxonomy_itemsrows, sorted byorderIndex({ id, listKey, value, orderIndex }[]).
POST /api/admin/config/taxonomy
- Mutation: yes — audit action
config.taxonomy.add. - Body:
{ listKey: TaxonomyListKey; value: string }.valueis trimmed;400if empty. - Response: the inserted
taxonomy_itemsrow (appended at the end of its list, i.e.orderIndex = <current length>).
DELETE /api/admin/config/taxonomy/:id
- Mutation: yes — audit action
config.taxonomy.remove. - Response:
{ id: string }.
Status transitions
GET /api/admin/config/status-transitions
- Mutation: no. Response: all rows from
status_transitions({ fromStatus, toStatus, allowed }[]) — this is the table Feed Review's status-change endpoint checks against.
POST /api/admin/config/status-transitions/toggle
Flips whether a given from → to transition is allowed (inserts the row if it doesn't exist yet, defaulting
the new row to allowed).
- Mutation: yes — audit action
config.status_transition.toggle. Throws400 Bad Requestiffrom === to. - Body:
{ from: string; to: string }. - Response:
{ fromStatus, toStatus, allowed: boolean }.
Branding
GET /api/admin/config/branding
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: the matching
city_brandingrow, or{ cityId, helpline: null, donationUpiId: null, landingHeadline: null }if none exists yet.
POST /api/admin/config/branding
- Mutation: yes — audit action
config.branding.update. Requires a non-emptynote. - Body:
{ helpline?: string; donationUpiId?: string; landingHeadline?: string; note: string; cityId?: string }. - Response: the updated
city_brandingrow.
Geography & Reference
Directory: apps/api/src/admin-geo/ — controller: geography-admin.controller.ts, base path
/api/admin/geo.
Cities
GET /api/admin/geo/cities
- Mutation: no. Response: array of
{ id, name, subdomain, status, bg, fg, dot, label }— the last four fields are a colored status pill (live|rollout|waitlist) derived fromcities.status.
POST /api/admin/geo/cities
- Mutation: yes — audit action
geo.city.create. - Body:
{ id: string; name: string; state?: string; subdomain?: string; status?: string }.idandnameare required (400otherwise);statusdefaults to'waitlist'. - Response: the inserted
citiesrow.
POST /api/admin/geo/cities/:id
- Mutation: yes — audit action
geo.city.update. Throws404 Not Foundfor an unknown city. - Body: a partial patch of the
citiescolumns. - Response: the updated
citiesrow.
Wards
GET /api/admin/geo/wards
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: array of
{ code, name, constituency, rail }, joiningwardswithjurisdictionsforconstituency(assembly constituency name).railis always"—"— it isn't modeled in this database (the public site's schematic ward map that shows rail lines is a frontend-only illustration, not backed by real data).
Pincode map
GET /api/admin/geo/pincode-map
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: array of
{ pincode, ward }, left-joiningpincodestowards.
Public submissions
GET /api/admin/geo/submissions
Citizen-suggested corrections to ward/official data.
- Mutation: no. Response: all rows from
public_submissions.
POST /api/admin/geo/submissions/:id/approve
POST /api/admin/geo/submissions/:id/reject
- Mutation: yes for both — audit actions
geo.submission.approved/geo.submission.rejected. Throws404 Not Foundif the submission doesn't exist. SetsreviewedBy/reviewedAtto the acting admin. - Response:
{ id, status: 'approved' | 'rejected' }.
public_submissions starts genuinely empty — there is no public-facing citizen submission flow yet to
populate it (that's public-site write-path territory), so this queue being empty reflects real state, not a
missing feature in this admin screen.
Officials & Accountability
Directory: apps/api/src/admin-officials/ — controller: officials-admin.controller.ts, base path
/api/admin/officials.
Directory
GET /api/admin/officials/directory
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: array of
{ id, role, jurisdiction, contact, term, withheld, active }fromofficials.contactis the phone number masked (e.g.+91 987XX X3210);jurisdictionfalls back from ward code to constituency code to"—".
Toggle name-withheld / toggle active
POST /api/admin/officials/:id/toggle-withheld
POST /api/admin/officials/:id/toggle-active
- Mutation: yes for both — audit actions
officials.toggle_withheld/officials.toggle_active. Throws404 Not Foundfor an unknown official. - Response:
{ id, nameWithheld: boolean }or{ id, active: boolean }respectively.
Update official
POST /api/admin/officials/:id
- Mutation: yes — audit action
officials.update. - Body: a partial patch of the
officialscolumns (name, designation, party, phone, email, term dates, office address/hours, etc.). - Response: the updated
officialsrow.
Responsibility chains
GET /api/admin/officials/chains
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: one entry per category —
{ category, chain: string, hasOverride: boolean }[]— wherechainis the fixed label"Local Police → Corporator → BMC Ward Officer → MLA → MP"(identical across every category today) andhasOverridereflects whether achain_overridesrow exists for that category.
POST /api/admin/officials/chains/:category
- Mutation: yes — audit action
officials.chain_override.set. - Body:
{ overrideChain: string[]; note: string }. - Response:
{ category, overrideChain, note }.
DELETE /api/admin/officials/chains/:category
- Mutation: yes — audit action
officials.chain_override.clear. - Response:
{ category }.
Chain overrides are stored and flagged (hasOverride) here, but are not yet consumed by the live
chain-of-responsibility computation used elsewhere in the app (detailOf(), which always computes the same
fixed 5-step chain regardless of category) — same "stored, not yet wired" status as feature flags and
scoring config.
Departments
GET /api/admin/officials/departments
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: all matching rows from
departments.
POST /api/admin/officials/departments/:id
- Mutation: yes — audit action
officials.department.update. Throws404 Not Foundfor an unknown department. - Body: a partial patch of the
departmentscolumns. - Response: the updated
departmentsrow.
Police stations
GET /api/admin/officials/police-stations
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: all matching rows from
police_stations. Read-only in this module — there is no update/create endpoint for police stations as of this writing.
Accountability scores
GET /api/admin/officials/scores
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: array of
{ wardId, ward, score, gradeLabel, gradeColor, hasOverride, annotation }for every ward that has a computedscore.scoreis the manual override value fromscore_overrideswhen one exists, otherwise the ward's storedscore.
POST /api/admin/officials/scores/recompute
- Mutation: yes — audit action
officials.scores.recompute. - Response:
{ recomputedAt: string; note: string }.
This endpoint is honest about what it does: there is no live scoring input pipeline to recompute from yet (that lands with a future SLA-sweep/score-refresh background job), so it does not run any scoring algorithm — it just records an audit entry and returns a timestamp with a note pointing to where the real pipeline will live, rather than pretending to have recomputed anything.
POST /api/admin/officials/scores/:targetType/:targetId/override
Sets or clears a manual override/annotation on a ward's or official's computed score.
- Mutation: yes — audit action
officials.score_override.set. - Params:
targetType—ward|official. - Body:
{ overrideScore: number | null; annotation?: string }. - Response:
{ targetType, targetId, overrideScore, annotation }.
Reporters
Directory: apps/api/src/admin-reporters/ — controller: reporters-admin.controller.ts, base path
/api/admin/reporters.
Applications
GET /api/admin/reporters/applications
Pending citizen-reporter applications.
- Mutation: no. Response:
reporter_applicationsrows withstatus = 'pending', most recent first.
POST /api/admin/reporters/applications/:id/approve
POST /api/admin/reporters/applications/:id/reject
- Mutation: yes for both — audit actions
reporters.application.approved/reporters.application.rejected. Throws404 Not Foundfor an unknown application. On approval, also inserts a newreportersrow (slug derived from the applicant's name;zonefalls back to"Unassigned"if no beat was given) — this is a no-op insert (onConflictDoNothing) if a reporter with that slug already exists. - Response:
{ id, status: 'approved' | 'rejected' }.
reporter_applications starts genuinely empty — there is no public "apply to be a reporter" flow yet to
populate it (public-site write-path territory), same as public_submissions in Geography.
Reporter management
GET /api/admin/reporters/management
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: array of
{ slug, name, beat, reports, score, gradeColor, suspended }fromreporters.scoreis the rawimpactcolumn value (a string, e.g."4.2") or"—"if unset;gradeColoris computed by scaling that value onto the shared grade bands.
POST /api/admin/reporters/:slug/toggle-suspend
- Mutation: yes — audit action
reporters.toggle_suspend. Throws404 Not Foundfor an unknown slug. - Response:
{ slug, suspended: boolean }.
Admins & Access
Directory: apps/api/src/admin-access/ — controller: admin-access.controller.ts, base path
/api/admin/access.
List accounts
GET /api/admin/access/accounts
- Mutation: no.
- Response: array of
{ id, name, email, role: string, cities: string, tfaLabel: '2FA ON' | '2FA OFF', lastActive: string, active }—role/cities/tfaLabel/lastActiveare pre-formatted display strings, not raw enum values.
Invite admin
POST /api/admin/access/invite
- Auth: requires
actor.role === 'super_admin'— this is checked directly in the service method (not viaRolesGuard); any other role gets403 Forbidden. - Mutation: yes — audit action
admins.invite. - Body:
{ email: string; role: AdminRole }.400 Bad Requestif the email is empty or an account with that email already exists. - Response:
{ id, email, role, tempPassword: string }.
There is no invite-email infrastructure yet, so this endpoint creates the account directly with a randomly-generated temporary password and returns it once, in the response, for the inviting admin to share out-of-band. A real invite-token/email flow is future work.
Toggle admin active
POST /api/admin/access/:id/toggle-active
- Mutation: yes — audit action
admins.toggle_active. - Throws
400 Bad Requestif an admin attempts to deactivate their own account;404 Not Foundfor an unknown admin ID. - Response:
{ id, active: boolean }.
Roles matrix
GET /api/admin/access/roles-matrix
- Mutation: no. Response:
{ roleCols: string[], permRows: { cat: string; cells: { on: boolean }[] }[] }— a fixed, hardcoded reference table (not read from the database) describing which of the four roles are intended to have access to seven permission categories (Feed moderation, Integrations, Configuration, Officials & geography, Reporters & events, Admin & RBAC, Audit log (read)). As noted in the intro, this matrix is descriptive, not enforced —RolesGuardis not currently applied to any route.
Citizen users
GET /api/admin/access/citizen-users
- Mutation: no. Response: always
[].
No citizen-account table/system exists yet in this backend (that's public-site write-path territory) — this endpoint returns a genuinely empty list rather than fabricating example users.
Notifications & Broadcast
Directory: apps/api/src/admin-notifications/ — controller: notifications.controller.ts, base path
/api/admin/notifications.
Send broadcast
POST /api/admin/notifications/broadcast
Queues (or schedules) a web-push broadcast message to an audience.
- Mutation: yes — audit action
notifications.broadcast.send. - Body:
{ audience: string; message: string; scheduledAt?: string | null }.audienceis'all'or a city ID.400 Bad Requestifmessageis blank. - Response:
{ id, status: 'queued' | 'scheduled', subscriberCount: number, note: string | null }.subscriberCountis a real count againstpush_subscriptions(filtered byaudienceunless it's'all').statusis'scheduled'ifscheduledAtwas given, else'queued'.
push_subscriptions is genuinely empty in practice today — the public citizen site has no service worker
and never prompts for web-push permission yet, so subscriberCount is honestly 0 for now, and the
response includes an explicit note saying so when it is. There is also no delivery pipeline that ever
marks a broadcast 'sent' — status can only ever be 'queued' or 'scheduled'.
Recent broadcasts
GET /api/admin/notifications/broadcasts
- Mutation: no. Response: the 10 most recent
broadcastsrows.
Templates
GET /api/admin/notifications/templates
- Mutation: no. Response: active rows from
push_templates({ id, key, name, body, active }).
Announcements
GET /api/admin/notifications/announcements
In-app announcement banners shown to citizens, per city.
- Mutation: no.
- Query:
cityId(optional — omit for all cities). - Response: matching
announcementsrows, most recent first (with a secondary sort onidto keep ordering stable across requests when several rows share the same seed-batchcreatedAt).
POST /api/admin/notifications/announcements/:id/toggle
- Mutation: yes — audit action
notifications.announcement.toggle. Throws404 Not Foundfor an unknown announcement. - Response:
{ id, active: boolean }.
Analytics & Reports
Directory: apps/api/src/admin-analytics/ — controller: analytics.controller.ts, base path
/api/admin/analytics.
This module is entirely read-only — none of its endpoints call AuditService, and everything it returns is
derived from tables that already back the public site (issues, wards, channel_mentions) or from the
same deterministic scoring service the public Administrators page uses (RoleContactService); nothing is
generated specifically for this view.
Trend series
GET /api/admin/analytics/series
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response:
{ volume: { day, count }[]; resolution: { day, count }[] }— real per-day reported/resolved counts for the last 14 days, zero-filled.
Ward leaderboard
GET /api/admin/analytics/leaderboard/wards
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: top 6 wards (of those with a seeded
resPct) by score, descending:{ ward, score, trend, gradeLabel, gradeColor, trendLabel, trendColor }[].
Official leaderboard
GET /api/admin/analytics/leaderboard/officials
- Mutation: no.
- Query:
cityId(defaultmumbai). - Response: top 6 officials by score, descending:
{ name, score, gradeLabel, gradeColor }[]. ReusesRoleContactService's deterministic hash-based scoring (the same one behind the public Administrators page) for Corporator/BMC Ward Officer per ward, and deduplicates MLA/MP entries by their real constituency code so a single MLA or MP shared by two wards is only listed once instead of appearing twice with two unrelated scores.
Channel performance
GET /api/admin/analytics/channel-performance
- Mutation: no.
- Response:
{ label, pct, color }[], the real share of inboundchannel_mentionsper channel, sorted descending by share. Returns[]if there are no mentions recorded yet, rather than a fabricated split.label/colorare read live fromchannel_configs(the same source the Integrations screen edits), not a hardcoded copy.
System/Ops
Directory: apps/api/src/admin-ops/ — controller: ops.controller.ts, base path /api/admin/ops.
Like Analytics, this module is entirely read-only. It reads the shared, immutable audit_log table and
reports honestly on the state of background subsystems; it has no mutations of its own. The two "scheduled
jobs" it reports as currently runnable are triggered by the frontend calling their own pre-existing
endpoints directly (officials.scores.recompute and the channel test-connection endpoint) — there is no
actual job scheduler invoking them.
Audit log
GET /api/admin/ops/audit
- Mutation: no.
- Query:
actorId(optional, filters to one admin),limit(optional, numeric, default 50). - Response: array of
{ id, actor: string, action, targetType, targetId, before, after, note, createdAt: string }, most recent first.actoris"System"for system-originated rows or the admin's display name (falling back to"Unknown admin"if the admin record is missing).
Audit actors
GET /api/admin/ops/audit/actors
- Mutation: no. Response:
{ id, name }[]— the distinct set of admins who have at least one audit log entry (an admin who has never triggered an audited mutation will not appear here).
Job queue status
GET /api/admin/ops/queue
- Mutation: no. Response:
{ implemented: false, note: string }— a fixed, honest stub: no background job-queue table exists yet in this backend. Thenotepoints to the implementation-plan section tracking it (aSKIP LOCKED-based job table, per the backend architecture).
Scheduled jobs
GET /api/admin/ops/jobs
- Mutation: no.
- Response: a fixed array of four job descriptors:
{ key, name, runnable: boolean, endpoint: string | null, method: string | null, lastRun: string | null, note: string | null }[].lastRunis a real timestamp looked up from the audit log (most recent matchingaction) where applicable. As of this writing:score-refresh—runnable: true, points at the officials score-recompute endpoint.webhook-health-check—runnable: true, checks X/Instagram/WhatsApp via the channel test-connection endpoint.sla-sweep—runnable: false,"Not yet implemented — will run via pg_cron once the worker's job-queue lands."config-digest—runnable: false,"Not yet implemented — no digest/email infrastructure exists yet."