Skip to main content

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 updates admins.lastLoginAt, but never calls AuditService.record(...).
  • Body: { email: string; password: string } — both required, or 400 Bad Request.
  • Response: { admin: AdminPrincipal } where AdminPrincipal = { id, email, name, role: AdminRole, cityScope: string[] | null }. Sets the cp_admin_session cookie. Throws 401 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 the admin_sessions row and the controller clears the cookie; no AuditService call.
  • Response: { ok: true }. Clears the cp_admin_session cookie.

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 to mumbai).
  • Response:
    {
    kpis: {
    key: string
    label: string
    value: string // e.g. "42", "—" for a placeholder
    delta: string | null
    good: boolean | null
    placeholder: boolean // true = no backing subsystem yet, see "honest placeholder" above
    }[]
    series: { day: string; reported: number; resolved: number }[] // 14 entries, oldest first, zero-filled
    needsAttention: [] // always empty today — see below
    }
    Real (non-placeholder) KPIs today: open issues, overdue/SLA-breached, resolution rate, reports today, reports this week — all computed directly from the issues table. 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. needsAttention is 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 (default mumbai), status (issue status, or all/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 }. slaLabel is computed from sla_config.window_hours (default 48h) and the issue's real elapsed time — see the note below on status vs. the SLA clock.

Get issue detail​

GET /api/admin/feed/issues/:id
  • Mutation: no. Throws 404 Not Found if the issue doesn't exist.
  • Response: { id, title, metaLine, status, chain, activity: {t, when}[], mediaTiles: {index, kind, redacted}[], forced, slaPaused, closed, hidden, pinned, chainRoleOverride }. chain is the 5-step responsibility chain (Local Police → Corporator → BMC Ward Officer → MLA → MP), adjusted for any chainRoleOverride set 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, 400 if missing).
  • Validates the transition against the status_transitions table (configurable in Configuration → Status transitions, see below) rather than a hardcoded matrix; throws 400 Bad Request if the transition isn't allowed. Also appends a row to issue_status_events.
  • Response: { status: string }.
note

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_pinned respectively.
  • Body: none.
  • Response: the full updated issue_admin_state row.

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_state row.

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; 400 if not a number).
  • Response: the full updated issue_admin_state row.

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 of spam, abuse, wronginfo, duplicate, other, or all/omitted.
  • Response: array of { id, reasonLabel, target, snippet, reporterCount, resolved, resolvedLabel, showBan }. showBan is true only 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. Throws 404 Not Found if 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 (default mumbai).
  • 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 in channel_configs (channel is one of whatsapp, instagram, x, platform). connectionStatus reports "demo" whenever demoMode is on, regardless of the stored connection state — it doesn't claim real connectivity until live credentials exist. errorRate is computed live from the real errorCount/requestCount counters, 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 action channel.test_connection (recorded on both the success and failure path). For channel = '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, updates channel_configs.connectionStatus to '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. Throws 404 Not Found for 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_mentions rows, 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 of x, 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 }.
note

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_logs rows ({ 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_config row — { 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 the default row.
  • Response: the updated outbound_config row.

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 (default mumbai).
  • Response: one entry per category in { category, office }[], where office is 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 the all department 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), from routing_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 plain Error, not a BadRequestException, so a missing category/keyword surfaces as a 500 rather than a 400 as of this writing.
  • Response: the inserted routing_keywords row.

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.

caution

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' and hub.verify_token matches the fixed dev token civicpulse_wa_verify (there's no per-channel-config verify token wired into this check yet), responds with the raw hub.challenge string. Otherwise 400 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.webhookSecret is set and an X-Hub-Signature-256 header 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_mentions with the sender's phone number masked (e.g. +91 987XX X3210) and a keyword-detected category/city hint.
  • Mutation: yes (inserts to channel_mentions, updates channel_configs.lastReceivedAt), but this path has no AdminPrincipal actor 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.

note

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. No note requirement on this endpoint.
  • Body: { enabled: boolean; note?: string }.
  • Response: { key, enabled }.
note

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_config row: { 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-empty note (see above).
  • Body: a partial patch of the sla_config columns above, plus note: string.
  • Response: the updated sla_config row.

Scoring config​

GET /api/admin/config/scoring
  • Mutation: no. Response: the singleton scoring_config row: { 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-empty note.
  • Body: a partial patch of the columns above, plus note: string.
  • Response: the updated scoring_config row.
note

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 of categories, priorities, flagReasons, eventTypes (TaxonomyListKey).
  • Response: the matching taxonomy_items rows, sorted by orderIndex ({ id, listKey, value, orderIndex }[]).
POST /api/admin/config/taxonomy
  • Mutation: yes — audit action config.taxonomy.add.
  • Body: { listKey: TaxonomyListKey; value: string }. value is trimmed; 400 if empty.
  • Response: the inserted taxonomy_items row (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. Throws 400 Bad Request if from === to.
  • Body: { from: string; to: string }.
  • Response: { fromStatus, toStatus, allowed: boolean }.

Branding​

GET /api/admin/config/branding
  • Mutation: no.
  • Query: cityId (default mumbai).
  • Response: the matching city_branding row, 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-empty note.
  • Body: { helpline?: string; donationUpiId?: string; landingHeadline?: string; note: string; cityId?: string }.
  • Response: the updated city_branding row.

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 from cities.status.
POST /api/admin/geo/cities
  • Mutation: yes — audit action geo.city.create.
  • Body: { id: string; name: string; state?: string; subdomain?: string; status?: string }. id and name are required (400 otherwise); status defaults to 'waitlist'.
  • Response: the inserted cities row.
POST /api/admin/geo/cities/:id
  • Mutation: yes — audit action geo.city.update. Throws 404 Not Found for an unknown city.
  • Body: a partial patch of the cities columns.
  • Response: the updated cities row.

Wards​

GET /api/admin/geo/wards
  • Mutation: no.
  • Query: cityId (default mumbai).
  • Response: array of { code, name, constituency, rail }, joining wards with jurisdictions for constituency (assembly constituency name). rail is 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 (default mumbai).
  • Response: array of { pincode, ward }, left-joining pincodes to wards.

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. Throws 404 Not Found if the submission doesn't exist. Sets reviewedBy/reviewedAt to the acting admin.
  • Response: { id, status: 'approved' | 'rejected' }.
note

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 (default mumbai).
  • Response: array of { id, role, jurisdiction, contact, term, withheld, active } from officials. contact is the phone number masked (e.g. +91 987XX X3210); jurisdiction falls 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. Throws 404 Not Found for 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 officials columns (name, designation, party, phone, email, term dates, office address/hours, etc.).
  • Response: the updated officials row.

Responsibility chains​

GET /api/admin/officials/chains
  • Mutation: no.
  • Query: cityId (default mumbai).
  • Response: one entry per category — { category, chain: string, hasOverride: boolean }[] — where chain is the fixed label "Local Police → Corporator → BMC Ward Officer → MLA → MP" (identical across every category today) and hasOverride reflects whether a chain_overrides row 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 }.
note

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 (default mumbai).
  • Response: all matching rows from departments.
POST /api/admin/officials/departments/:id
  • Mutation: yes — audit action officials.department.update. Throws 404 Not Found for an unknown department.
  • Body: a partial patch of the departments columns.
  • Response: the updated departments row.

Police stations​

GET /api/admin/officials/police-stations
  • Mutation: no.
  • Query: cityId (default mumbai).
  • 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 (default mumbai).
  • Response: array of { wardId, ward, score, gradeLabel, gradeColor, hasOverride, annotation } for every ward that has a computed score. score is the manual override value from score_overrides when one exists, otherwise the ward's stored score.
POST /api/admin/officials/scores/recompute
  • Mutation: yes — audit action officials.scores.recompute.
  • Response: { recomputedAt: string; note: string }.
note

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_applications rows with status = '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. Throws 404 Not Found for an unknown application. On approval, also inserts a new reporters row (slug derived from the applicant's name; zone falls 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' }.
note

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 (default mumbai).
  • Response: array of { slug, name, beat, reports, score, gradeColor, suspended } from reporters. score is the raw impact column value (a string, e.g. "4.2") or "—" if unset; gradeColor is computed by scaling that value onto the shared grade bands.
POST /api/admin/reporters/:slug/toggle-suspend
  • Mutation: yes — audit action reporters.toggle_suspend. Throws 404 Not Found for 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/lastActive are 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 via RolesGuard); any other role gets 403 Forbidden.
  • Mutation: yes — audit action admins.invite.
  • Body: { email: string; role: AdminRole }. 400 Bad Request if the email is empty or an account with that email already exists.
  • Response: { id, email, role, tempPassword: string }.
note

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 Request if an admin attempts to deactivate their own account; 404 Not Found for 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 — RolesGuard is not currently applied to any route.

Citizen users​

GET /api/admin/access/citizen-users
  • Mutation: no. Response: always [].
note

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 }. audience is 'all' or a city ID. 400 Bad Request if message is blank.
  • Response: { id, status: 'queued' | 'scheduled', subscriberCount: number, note: string | null }. subscriberCount is a real count against push_subscriptions (filtered by audience unless it's 'all'). status is 'scheduled' if scheduledAt was given, else 'queued'.
note

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 broadcasts rows.

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 announcements rows, most recent first (with a secondary sort on id to keep ordering stable across requests when several rows share the same seed-batch createdAt).
POST /api/admin/notifications/announcements/:id/toggle
  • Mutation: yes — audit action notifications.announcement.toggle. Throws 404 Not Found for 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 (default mumbai).
  • 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 (default mumbai).
  • 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 (default mumbai).
  • Response: top 6 officials by score, descending: { name, score, gradeLabel, gradeColor }[]. Reuses RoleContactService'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 inbound channel_mentions per channel, sorted descending by share. Returns [] if there are no mentions recorded yet, rather than a fabricated split. label/color are read live from channel_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. actor is "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. The note points to the implementation-plan section tracking it (a SKIP 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 }[]. lastRun is a real timestamp looked up from the audit log (most recent matching action) 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."