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: targetTypeward | 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-refreshrunnable: true, points at the officials score-recompute endpoint.
    • webhook-health-checkrunnable: true, checks X/Instagram/WhatsApp via the channel test-connection endpoint.
    • sla-sweeprunnable: false, "Not yet implemented — will run via pg_cron once the worker's job-queue lands."
    • config-digestrunnable: false, "Not yet implemented — no digest/email infrastructure exists yet."