# EMS API Documentation

Base URL:

```text
https://ems.armsit.ng
```

Authentication uses bearer tokens returned by `/api/auth/login`.

```http
Authorization: Bearer <token>
Content-Type: application/json
```

## Authentication

### POST `/api/auth/login`

```json
{
  "email": "admin@example.com",
  "password": "password"
}
```

Returns a session immediately when MFA is not required:

```json
{
  "token": "64-character-token",
  "user": {
    "id": 1,
    "email": "admin@example.com",
    "name": "Admin",
    "role": "admin",
    "active": 1
  }
}
```

When MFA is enabled globally and required for the user, password verification returns an MFA challenge instead of a session token:

```json
{
  "mfaRequired": true,
  "setupRequired": false,
  "challenge": "64-character-challenge-token",
  "user": {
    "id": 1,
    "email": "admin@example.com",
    "name": "Admin",
    "role": "admin",
    "mfa_enabled": 1,
    "mfa_confirmed": 1
  }
}
```

If the user has MFA required but has not enrolled Google Authenticator yet, `setupRequired` is `true` and the response also includes:

```json
{
  "secret": "BASE32SETUPKEY",
  "otpauthUri": "otpauth://totp/Nigeria%20Election%20Monitor%3Aadmin%40example.com?secret=..."
}
```

The login page displays the QR code and setup key for Google Authenticator.

### POST `/api/auth/mfa/verify`

Verifies the 6-digit Google Authenticator code and creates the real session token.

```json
{
  "challenge": "64-character-challenge-token",
  "code": "123456"
}
```

Returns:

```json
{
  "token": "64-character-token",
  "user": {
    "id": 1,
    "email": "admin@example.com",
    "name": "Admin",
    "role": "admin",
    "mfa_enabled": 1,
    "mfa_confirmed": 1
  }
}
```

### POST `/api/auth/logout`

Requires auth. Deletes the current session token.

### MFA Settings

Admins can enable MFA globally in Business Settings under `Security`:

- `security_mfa_enabled`: enables or disables MFA enforcement.
- `security_mfa_issuer`: issuer label shown in Google Authenticator.
- `security_mfa_challenge_minutes`: challenge expiry window.

Admins can require or disable MFA per user from the Admin page. When MFA is required for a user, that user completes Google Authenticator setup on the next successful password login.

## Official INEC Updates

These endpoints are admin-only. The system does not declare winners or project winners before official certification; imported results remain governed by the `results_mode`, no-winner-projection setting, and disclaimer settings.

### Configure Source

Set these values in the Business Settings page under `INEC API`:

- `official_results_api_url`: HTTPS JSON feed URL from an approved official source.
- `official_results_api_auth_header`: Optional full header, for example `Authorization: Bearer <token>`.
- `inec_sync_enabled`: Must be `1` before live sync can run.

Production note: INEC publishes official result pages and operates the IReV portal, but no public official JSON API URL has been verified for automated sync. For integration testing, this app includes a sample JSON feed:

```text
https://ems.armsit.ng/sample-official-results.json
```

Sample auth header for testing:

```text
Authorization: Bearer sample-official-results-token
```

Use the official INEC results page as the public reference URL:

```text
https://www.inecnigeria.org/election-results/
```

### Expected INEC JSON Shape

```json
{
  "results": [
    {
      "state": "Lagos",
      "status": "provisional",
      "reportingUnits": 7241,
      "totalUnits": 13125,
      "turnout": 48,
      "parties": [
        { "name": "APC", "votes": 611204 },
        { "name": "LP", "votes": 579830 }
      ]
    }
  ]
}
```

Snake-case aliases are also accepted for `reporting_units` and `total_units`. Party name can be sent as `name` or `party`.

### GET `/api/inec/status`

Requires admin auth. Returns sync configuration and recent import logs.

### POST `/api/inec/sync`

Requires admin auth. Pulls JSON from `official_results_api_url` and upserts it into the results tables.

Returns:

```json
{
  "ok": true,
  "rowsImported": 4
}
```

### POST `/api/inec/import`

Requires admin auth. Manual fallback for uploading official result JSON when no public INEC feed is available.

```json
{
  "sourceName": "INEC",
  "sourceUrl": "manual-upload",
  "results": []
}
```

## Incident Reporting With Geo-Tagging

## Report Security

### GET `/api/security/report-config`

Public endpoint used by the browser before submitting reports.

```json
{
  "encryptionEnabled": true,
  "publicJwk": "{\"kty\":\"RSA\",...}",
  "minDescriptionLength": 40,
  "requireClientTimestamp": true
}
```

When `report_encryption_enabled` is enabled and `report_encryption_public_jwk` is configured, the browser encrypts sensitive report content before submission using:

- AES-GCM for the report payload.
- RSA-OAEP SHA-256 to wrap the AES key.
- A public JWK stored in Business Settings.

The server stores only the encrypted blob in `encrypted_payload`, plus metadata needed for verification. The private key must be kept outside the server by the election monitoring team.

Identity protection controls:

- Reporter contact is never stored in plain text.
- Reporter contact is keyed-hashed with the app secret before storage, reducing phone/email dictionary-guessing risk.
- Public incident responses do not include `reporter_hash`, `encrypted_payload`, `client_nonce`, exact latitude/longitude, or verification fingerprints.
- Public media names are replaced with `[evidence on file]` so filenames cannot reveal a person or device.
- Sensitive incident metadata requires admin authentication with `include=sensitive`.

### POST `/api/incidents`

Public endpoint. The Report tab captures photo evidence, free-text incident details, GPS/map coordinates, nearest polling-unit context, and a read-only capture timestamp. The server uses its own `created_at` timestamp as the authoritative incident time, so submitted incident timestamps are not user-editable.

```json
{
  "category": "Intimidation",
  "severity": "critical",
  "state": "Rivers",
  "lga": "Port Harcourt",
  "pollingUnit": "PU-RIV-PHC-044",
  "description": "Observed intimidation near the voting queue.",
  "latitude": 4.8156,
  "longitude": 7.0498,
  "mediaName": "evidence.jpg",
  "reporterContact": "optional@example.com",
  "anonymous": true,
  "clientNonce": "browser-generated-uuid",
  "clientCreatedAt": "2026-05-10T12:00:00.000Z"
}
```

`clientCreatedAt` is a browser capture signal only. The persisted incident timestamp is assigned by the server/database and returned as `created_at` and `capturedAt`.

Geo verification fields in the response:

- `geo_status`: `matched_polling_unit`, `near_polling_unit`, `outside_radius`, `unverified`, or `not_provided`.
- `geo_distance_m`: Distance in meters to the nearest known polling unit.
- `matched_polling_unit_id`: Nearest matched polling unit ID.

The allowed radius is controlled by `geo_verification_radius_m` in Business Settings.

Public incident responses intentionally omit exact GPS coordinates to reduce retaliation risk. Moderators can access exact coordinates only through the authenticated sensitive incident feed. Location is selected by current GPS or by clicking the Google Map on the report form when `google_maps_api_key` is configured. Once coordinates are captured, the browser auto-fills state, LGA, and polling unit from the nearest stored polling unit.

### GET `/api/incidents?include=sensitive`

Requires admin auth. Returns moderator-only incident metadata, including exact coordinates, keyed reporter hash, encryption status, verification fingerprint, and fake-signal metadata. Use this feed only for authorized verification workflows.

Anti-fake verification fields:

- `verification_fingerprint`: server-side hash for duplicate detection.
- `fake_signals`: JSON list of suspicious signals, such as `short_description`, `missing_geo`, `outside_polling_radius`, `duplicate_fingerprint`, `duplicate_report`, `invalid_client_nonce`, or `stale_or_missing_client_time`.
- `validation_level`: `passed`, `review`, `suspicious`, `duplicate`, or `duplicate_review`.
- `duplicate_of`: matching incident ID when an exact or near duplicate is detected.
- `status`: `verified`, `pending`, `flagged`, or `duplicate`.
- Honeypot spam is rejected when `fake_reject_honeypot` is enabled.

Duplicate filtering:

- Exact duplicates are matched by `verification_fingerprint`.
- Near duplicates are matched by state, LGA, category, time window, and close latitude/longitude.
- `incident_duplicate_window_hours` controls how far back the backend searches.
- `incident_duplicate_action` controls whether duplicates are marked as `duplicate` or queued as `pending` review.

Suspicious entry flagging:

- Reports outside the polling radius, with invalid nonces, or with too many signals are marked `flagged`.
- `incident_flag_signal_count` controls how many signals trigger the suspicious state.
- Flagged and duplicate reports are not auto-verified and stay out of the verified incident stream until a moderator reviews them.

Security settings:

- `report_encryption_enabled`
- `report_encryption_public_jwk`
- `incident_duplicate_window_hours`
- `incident_duplicate_action`
- `incident_flag_signal_count`
- `fake_min_description_length`
- `fake_reject_honeypot`
- `fake_duplicate_window_hours`
- `fake_require_client_timestamp`

## Public Data

### GET `/api/dashboard`

Returns dashboard totals, official result rows, verified incidents, alerts, polling units, `resultsMode`, official source metadata, and the active compliance summary.

Important dashboard rules:

- `results` comes from the official results tables populated by `/api/inec/sync` or `/api/inec/import`.
- `officialSource` identifies the configured official source and the latest successful sync when available.
- `incidents` contains only reports with `status = verified`.
- `alerts` powers both the Alerts page and the Home Screen election news list.
- Pending, flagged, and duplicate reports are excluded from the public dashboard and remain available through incident/moderation APIs.

### GET `/api/compliance`

Returns the public legal and operating rules used by the frontend compliance page.

```json
{
  "enabled": true,
  "jurisdiction": "Nigeria",
  "legalFramework": "Electoral Act 2022; INEC Regulations and Guidelines for the Conduct of Elections 2022",
  "electoralActUrl": "https://www.inecnigeria.org/electoral-act-2022/",
  "guidelinesUrl": "https://www.inecnigeria.org/downloads-all/regulations-and-guidelines-for-the-conduct-of-elections-2022/",
  "noWinnerProjection": true,
  "publicNotice": "This civic monitoring platform publishes citizen observations and provisional dashboards only...",
  "takedownEmail": "legal@ems.armsit.ng",
  "securityEscalationContact": "Forward urgent threats to official security and election authorities.",
  "dataRetentionDays": 180,
  "misinformationPolicy": "Reports that appear fabricated, defamatory, dangerous, or unverifiable are queued for review..."
}
```

Compliance settings are editable by admins in Business Settings under `Compliance`. The app is designed to support Nigerian election-law compliance by keeping official certification with INEC or legally authorized election officials, displaying public disclaimers, disabling winner projection, and preserving review/escalation contacts. When `resultsMode` is not `certified`, the frontend displays party rows alphabetically instead of ranking them by votes, and labels the results as provisional so the UI does not imply a winner or leading candidate. This is an operational control and should still be reviewed by qualified Nigerian election counsel before live use.

### GET `/api/results`

Returns provisional/certified result rows and the election disclaimer.

### GET `/api/polling-units`

Returns polling unit coordinates used for geo-tag checks.

Example response:

```json
[
  {
    "id": "PU-LAG-IKE-001",
    "name": "Ikeja Grammar School",
    "state": "Lagos",
    "lga": "Ikeja",
    "ward": "Alausa",
    "opens_at": "08:30",
    "closes_at": "14:30",
    "accessibility": "Ramp access, shaded queue area",
    "latitude": "6.6018000",
    "longitude": "3.3515000"
  }
]
```

### GET `/api/polling-units/map-pins`

Returns lightweight polling-unit pins for the current map viewport. This is the preferred endpoint for Google Maps because the full national polling-unit dataset is large.

Query parameters:

- `north`, `south`, `east`, `west`: Numeric map bounds.
- `q`: Optional search term for ID, name, state, LGA, or ward.
- `limit`: Optional limit from 50 to 5000. Defaults to 1500.

Sample:

```text
GET /api/polling-units/map-pins?north=14.5&south=4&east=15&west=2&limit=3000
```

Sample response:

```json
{
  "total": 117796,
  "limit": 3000,
  "pins": [
    {
      "id": "PU-NG-000001",
      "name": "LGEA SCHOOL INABE, INABE/EFOJA",
      "state": "Kogi",
      "lga": "Olamaboro",
      "ward": "Olamaboro V",
      "latitude": "7.1783590",
      "longitude": "7.5771629"
    }
  ]
}
```

The national import contains all available polling-unit rows from the imported INEC-derived dataset. Pins are only returned for rows with valid Nigeria-bounds GPS coordinates.

### GET `/api/map-config`

Returns public map provider configuration for the browser.

```json
{
  "provider": "google",
  "apiKey": "AIza..."
}
```

Set `google_maps_api_key` in Business Settings under `INEC API`.

Current production setup uses Google Maps for live polling-unit markers, incident location selection, and route directions. The configured key is stored in `business_settings.google_maps_api_key`; do not hard-code it in external clients.

### POST `/api/polling-units`

Requires admin auth. Creates a polling unit.

Sample request:

```http
POST /api/polling-units
Authorization: Bearer <admin-token>
Content-Type: application/json
```

```json
{
  "id": "PU-ABJ-AMAC-101",
  "name": "Central Primary School",
  "state": "FCT",
  "lga": "AMAC",
  "ward": "Garki",
  "opens_at": "08:30",
  "closes_at": "14:30",
  "accessibility": "Step-free entrance",
  "latitude": 9.05785,
  "longitude": 7.49508
}
```

Sample response:

```json
{
  "id": "PU-ABJ-AMAC-101",
  "name": "Central Primary School",
  "state": "FCT",
  "lga": "AMAC",
  "ward": "Garki",
  "opens_at": "08:30",
  "closes_at": "14:30",
  "accessibility": "Step-free entrance",
  "latitude": 9.05785,
  "longitude": 7.49508
}
```

### PUT `/api/polling-units/{id}`

Requires admin auth. Updates a polling unit. The ID may also be changed by sending a new `id` in the JSON body.

Sample GPS update:

```json
{
  "id": "PU-ABJ-AMAC-101",
  "name": "Central Primary School",
  "state": "FCT",
  "lga": "AMAC",
  "ward": "Garki",
  "opens_at": "08:30",
  "closes_at": "14:30",
  "accessibility": "Step-free entrance",
  "latitude": 9.05791,
  "longitude": 7.49512
}
```

### POST `/api/polling-units/{id}/vote-collections`

Requires admin auth. Creates a vote collection record for one polling unit. These records are field collection data only; they do not certify results, declare winners, or update the official results dashboard by themselves.

Required fields:

- `election_name`: Election label, for example `2027 Presidential Election`.
- `registered_voters`: Total registered voters at the polling unit.
- `accredited_voters`: Accredited voter count.
- `valid_votes`: Valid vote count.
- `rejected_votes`: Rejected/spoiled vote count.
- `total_votes`: Total ballots counted. Must equal `valid_votes + rejected_votes`.
- `collected_at`: Polling-unit collection timestamp, for example `2027-02-27 15:45:00`.
- `parties`: One or more party vote rows. Sum of party `votes` must equal `valid_votes`.

Optional fields:

- `collection_status`: `submitted`, `reviewed`, `certified`, or `rejected`. Defaults to `submitted`.
- `latitude` / `longitude`: GPS point of the submitted collection. Defaults to the polling unit coordinates when omitted.
- `source`: Collection source label.
- `notes`: Short review note.

Sample request:

```http
POST /api/polling-units/PU-ABJ-AMAC-101/vote-collections
Authorization: Bearer <admin-token>
Content-Type: application/json
```

```json
{
  "election_name": "2027 Presidential Election",
  "collection_status": "submitted",
  "registered_voters": 750,
  "accredited_voters": 512,
  "valid_votes": 500,
  "rejected_votes": 12,
  "total_votes": 512,
  "collected_at": "2027-02-27 15:45:00",
  "latitude": 9.05791,
  "longitude": 7.49512,
  "source": "Field observer",
  "notes": "Entered from signed polling-unit result sheet.",
  "parties": [
    { "party_code": "APC", "party_name": "All Progressives Congress", "votes": 210 },
    { "party_code": "LP", "party_name": "Labour Party", "votes": 190 },
    { "party_code": "PDP", "party_name": "Peoples Democratic Party", "votes": 100 }
  ]
}
```

Sample response:

```json
{
  "id": "31f6f96c-6b69-47f9-9d25-12d0f79cfb6c",
  "polling_unit_id": "PU-ABJ-AMAC-101",
  "election_name": "2027 Presidential Election",
  "collection_status": "submitted",
  "registered_voters": 750,
  "accredited_voters": 512,
  "valid_votes": 500,
  "rejected_votes": 12,
  "total_votes": 512,
  "parties": [
    { "party_code": "APC", "party_name": "All Progressives Congress", "votes": 210 },
    { "party_code": "LP", "party_name": "Labour Party", "votes": 190 },
    { "party_code": "PDP", "party_name": "Peoples Democratic Party", "votes": 100 }
  ],
  "provisional_notice": "Polling-unit vote collections are field records only and do not declare or project winners."
}
```

### GET `/api/polling-units/{id}/vote-collections`

Returns vote collection records for one polling unit, including party rows and the provisional notice.

### GET `/api/vote-collections`

Returns the latest vote collection records across polling units. Optional filter:

```text
/api/vote-collections?polling_unit_id=PU-ABJ-AMAC-101
```

The frontend uses Google Maps for:

- marker display for all polling stations,
- incident location selection on the report form,
- clicking a map location to fill latitude/longitude while editing,
- directions links through Google Maps Directions URLs,
- geo-tag verification support for incident reports.

### GET `/api/alerts`

Returns election advisories.

Query filters:

```text
/api/alerts?region_id=3&alert_type=security&level=critical
```

`alert_type` can be `general`, `results`, `security`, `turnout`, or `incident`.

### GET `/api/regions`

Returns active region lookup rows used by alert dropdowns and filters.

```json
[
  { "id": 1, "name": "National", "code": "NG", "region_type": "national" }
]
```

### GET `/api/lookups`

Returns country, timezone, geopolitical region, state/FCT, and LGA lookup values. Public reads return active rows. Admin reads can include inactive rows.

Sample:

```text
GET /api/lookups?type=lga
GET /api/lookups?type=state
GET /api/lookups?include_inactive=1
```

Sample response:

```json
[
  {
    "id": 46,
    "lookup_type": "lga",
    "code": "NG-AB-001",
    "name": "Aba North",
    "parent_name": "Abia",
    "latitude": "5.3333333",
    "longitude": "7.3166667",
    "timezone": "Africa/Lagos",
    "active": 1
  }
]
```

Default data includes Nigeria, West Africa Time, 6 geopolitical regions, 37 state/FCT rows, and 774 LGA rows. Polling-unit rows are managed through `/api/polling-units` because they include map and accessibility fields.

### POST `/api/lookups`

Requires admin auth. Creates a lookup value.

```json
{
  "lookup_type": "state",
  "code": "LA",
  "name": "Lagos",
  "parent_id": 8,
  "latitude": 6.5190323,
  "longitude": 3.3395815,
  "timezone": "Africa/Lagos",
  "sort_order": 25,
  "active": true
}
```

### PUT `/api/lookups/{id}`

Requires admin auth. Updates a lookup value with the same fields as `POST /api/lookups`.

### DELETE `/api/lookups/{id}`

Requires admin auth. Deletes a lookup value. Child lookup rows are retained with their parent cleared.

### POST `/api/alerts`

Requires admin auth unless disabled in Business Settings. Creates an election alert and marks whether browser notifications should be shown to subscribed users while the app is open.

```json
{
  "title": "Turnout update",
  "region_id": 1,
  "alert_type": "turnout",
  "level": "info",
  "body": "Turnout reports are now available for selected polling units.",
  "source": "Monitoring desk",
  "push_enabled": true,
  "expires_at": "2026-05-10 18:00"
}
```

The frontend supports browser notification permission and polls for new alerts every minute while open.
The Alerts tab also works as a notification center: unread alerts are highlighted, summary counters show unread and critical developments, users can mark alerts as read, and browser notifications open the Alerts tab when clicked.
