diff --git a/k8s/docs-website/k8s-aks/airqo-docs-website/values-stage.yaml b/k8s/docs-website/k8s-aks/airqo-docs-website/values-stage.yaml index e077e5d528..0f944d8e03 100644 --- a/k8s/docs-website/k8s-aks/airqo-docs-website/values-stage.yaml +++ b/k8s/docs-website/k8s-aks/airqo-docs-website/values-stage.yaml @@ -1,7 +1,7 @@ replicaCount: 2 image: repository: airqoacr.azurecr.io/airqo-stage-docs - tag: stage-60a6107f-1780390970 + tag: stage-6acf7cbe-1780867781 pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: '' diff --git a/k8s/platform/k8s-aks/airqo-platform/values-stage.yaml b/k8s/platform/k8s-aks/airqo-platform/values-stage.yaml index 49527837c3..5cf12da782 100644 --- a/k8s/platform/k8s-aks/airqo-platform/values-stage.yaml +++ b/k8s/platform/k8s-aks/airqo-platform/values-stage.yaml @@ -1,7 +1,7 @@ replicaCount: 1 image: repository: airqoacr.azurecr.io/airqo-stage-next-platform - tag: stage-6472f849-1780570227 + tag: stage-67057238-1780943479 pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: '' diff --git a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml index 37ca8d2cec..cb0667a4e8 100644 --- a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml +++ b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml @@ -1,7 +1,7 @@ replicaCount: 2 image: repository: airqoacr.azurecr.io/airqo-vertex - tag: prod-1786c96c-1780691136 + tag: prod-a3a8f47b-1780838720 pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: '' diff --git a/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml b/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml index 0344355133..cbae978709 100644 --- a/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml +++ b/k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml @@ -1,7 +1,7 @@ replicaCount: 2 image: repository: airqoacr.azurecr.io/airqo-stage-vertex - tag: stage-d3209b04-1780831845 + tag: stage-b98de37b-1780853334 pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: '' diff --git a/src/docs-website/docs/api/for-cities/intro.md b/src/docs-website/docs/api/for-cities/intro.md index 7a58240d4b..a870eb8d17 100644 --- a/src/docs-website/docs/api/for-cities/intro.md +++ b/src/docs-website/docs/api/for-cities/intro.md @@ -41,13 +41,15 @@ If a device belongs to an organisation's private Cohort, it will **not** appear | Historical measurements | `POST /api/v3/public/analytics/data-download` | | Spatial heatmap (city) | `GET /api/v2/spatial/heatmaps/{GRID_ID}?token={SECRET_TOKEN}` | | All available heatmaps | `GET /api/v2/spatial/heatmaps?token={SECRET_TOKEN}` | +| Daily forecasts | `GET /api/v2/predict/daily-forecasting/?grid_id={GRID_ID}&token={SECRET_TOKEN}` | +| Hourly forecasts | `GET /api/v2/predict/hourly-forecasting/?grid_id={GRID_ID}&token={SECRET_TOKEN}` | --- ## Finding your Grid ID -- **Browse the metadata endpoints** at [docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata) -- **Filter by location** using the `admin_level` query parameter (country, province, or city) +- **Browse the [Metadata API](../reference/metadata.md#grid-summary-with-site-details):** call `GET /api/v2/devices/grids/summary?admin_level=city` to list all city-level grids with their site details +- **Filter by location** using the `admin_level` query parameter (`country`, `province`, `city`, `district`) - **Example:** The Grid ID for Nairobi is `64b7ac8fd7249f0029feca80` --- @@ -60,7 +62,7 @@ If a device belongs to an organisation's private Cohort, it will **not** appear | Spatial heatmaps | ✅ Free | Base64 PNG with boundary coordinates | | Historical calibrated data | ✅ Standard+ | Via Analytics API | | Raw sensor readings | ✅ Standard+ | Via Analytics API | -| Air quality forecasts | ✅ Premium | Via site or device ID from your Grid | +| Air quality forecasts | ✅ Premium | Via `grid_id` or per-site `site_id` — see [Forecast API](../forecasts/overview.md) | --- @@ -79,4 +81,5 @@ City authorities already using Grid ID access: - [Fetch recent measurements →](./recent-measurements.md) - [Access historical data →](./historical-data.md) - [Generate spatial heatmaps →](./spatial-heatmaps.md) +- [Get air quality forecasts →](../forecasts/overview.md) - [Set up your account →](../getting-started/authentication.md) diff --git a/src/docs-website/docs/api/for-partners/intro.md b/src/docs-website/docs/api/for-partners/intro.md index bdd89c8146..570478ab68 100644 --- a/src/docs-website/docs/api/for-partners/intro.md +++ b/src/docs-website/docs/api/for-partners/intro.md @@ -33,12 +33,14 @@ At this time, Cohorts are created and configured by the AirQo team on behalf of ## Your Cohort ID endpoint structure -Once you have your Cohort ID, two endpoints cover your primary data needs: +Once you have your Cohort ID, these endpoints cover your primary data needs: | Data | Endpoint | |------|----------| | Recent measurements (last 7 days) | `GET /api/v2/devices/measurements/cohorts/{COHORT_ID}?token={SECRET_TOKEN}` | | Historical measurements | `POST /api/v3/public/analytics/data-download` | +| Daily forecasts | `GET /api/v2/predict/daily-forecasting/?cohort_id={COHORT_ID}&token={SECRET_TOKEN}` | +| Hourly forecasts | `GET /api/v2/predict/hourly-forecasting/?cohort_id={COHORT_ID}&token={SECRET_TOKEN}` | --- @@ -52,7 +54,7 @@ To change the privacy setting of your Cohort, contact [support@airqo.net](mailto ## Getting your Cohort ID -- **Public Cohorts:** Browse the metadata endpoints at [docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata) +- **Public Cohorts:** Use the [Metadata API](../reference/metadata.md#list-all-public-cohorts) — call `GET /api/v2/devices/metadata/cohorts` to list all publicly visible cohorts - **Private Cohorts:** Contact [support@airqo.net](mailto:support@airqo.net) — your Cohort ID will be shared securely --- @@ -64,7 +66,7 @@ To change the privacy setting of your Cohort, contact [support@airqo.net](mailto | Recent hourly measurements | ✅ Free | Last 7 days | | Historical calibrated data | ✅ Standard+ | Via Analytics API | | Raw sensor readings | ✅ Standard+ | Via Analytics API | -| Air quality forecasts | ❌ | Not currently available per Cohort ID | +| Air quality forecasts | ✅ Premium | Via `cohort_id` on the [Forecast API](../forecasts/overview.md) | | Spatial heatmaps | ❌ | Heatmaps use Grid ID | --- @@ -73,4 +75,5 @@ To change the privacy setting of your Cohort, contact [support@airqo.net](mailto - [Fetch recent measurements →](./recent-measurements.md) - [Access historical data →](./historical-data.md) +- [Get air quality forecasts →](../forecasts/overview.md) - [Set up your account →](../getting-started/authentication.md) diff --git a/src/docs-website/docs/api/forecasts/overview.md b/src/docs-website/docs/api/forecasts/overview.md index d89711bc38..357c8b4f98 100644 --- a/src/docs-website/docs/api/forecasts/overview.md +++ b/src/docs-website/docs/api/forecasts/overview.md @@ -5,7 +5,7 @@ sidebar_label: Forecast API # Forecast API -Get hourly and daily air quality predictions for up to 7 days ahead, with health recommendations based on predicted pollution levels. +The Predict API provides site-level PM2.5 air quality forecasts generated by AirQo forecasting models. The service supports both daily forecasts (7-day horizon) and hourly forecasts (up to 240 hours ahead by default). Forecasts can be retrieved for individual monitoring sites, grids, or cohorts of monitoring sites. :::info Tier requirement Forecasts require a **Premium Tier** subscription. @@ -13,242 +13,308 @@ Forecasts require a **Premium Tier** subscription. --- -## What you get - -- **7-day hourly forecasts** — one prediction per hour for the next 168 hours -- **7-day daily forecasts** — one prediction per day for the next 7 days -- **Health tips** — contextual recommendations based on predicted PM2.5 levels -- **Forecast metadata** — model version, generation timestamp, and location details - -Forecasts are available for any active monitoring site or device. They are regenerated every 6 hours (00:00, 06:00, 12:00, 18:00 UTC). - ---- - -## Endpoints - -### Hourly forecasts - -#### By Device ID +## Daily Forecasting Endpoint -``` -GET https://api.airqo.net/api/v2/predict/hourly-forecast?device_id={DEVICE_ID}&token={SECRET_TOKEN} +```http +GET /api/v2/predict/daily-forecasting/ ``` -#### By Site ID +Returns PM2.5 forecasts for the next 7 days, grouped by monitoring site. -``` -GET https://api.airqo.net/api/v2/predict/hourly-forecast?site_id={SITE_ID}&token={SECRET_TOKEN} -``` - -### Daily forecasts +### Supported Parameters -#### By Device ID +| Parameter | Description | +|-----------|-------------| +| `site_id` | Forecast for a single site | +| `grid_id` | Forecasts for all sites within a grid | +| `cohort_id` | Forecasts for all sites within a cohort | +| `scope` | Optional — `"grid"` or `"cohort"` to disambiguate when a scope ID could match either type | -``` -GET https://api.airqo.net/api/v2/predict/daily-forecast?device_id={DEVICE_ID}&token={SECRET_TOKEN} -``` +Only one of `site_id`, `grid_id`, or `cohort_id` may be provided per request. -#### By Site ID +### Response Structure -``` -GET https://api.airqo.net/api/v2/predict/daily-forecast?site_id={SITE_ID}&token={SECRET_TOKEN} +```json +{ + "success": true, + "data": { + "start_date": "2026-06-03", + "end_date": "2026-06-09", + "days": 7, + "total": 1, + "units": { + "pm2_5": "ug/m3", + "air_temperature": "degC", + "relative_humidity": "%", + "air_pressure_at_sea_level": "hPa", + "precipitation_amount": "mm", + "cloud_area_fraction": "%", + "wind_speed": "m/s", + "wind_from_direction": "compass", + "forecast_confidence": "%" + }, + "forecasts": [ + { + "site_details": { + "site_id": "64f7b3e8c9d25a0013f2d456", + "site_name": "Kampala Central", + "site_latitude": 0.3476, + "site_longitude": 32.5825 + }, + "forecasts": [ + { + "date": "2026-06-03", + "forecast": { + "pm2_5_mean": 28.4, + "pm2_5_low": 22.1, + "pm2_5_high": 34.8, + "pm2_5_min": 19.0, + "pm2_5_max": 37.2, + "forecast_confidence": 84.5 + }, + "aqi": { + "category": "Moderate", + "label": "Air quality is acceptable for most people.", + "trend_message": "Air pollution may increase over the next 24 hours." + }, + "met": { + "air_temperature": 24.7, + "relative_humidity": 72.1, + "air_pressure_at_sea_level": 1012.3, + "precipitation_amount": 0.0, + "cloud_area_fraction": 45.0, + "wind_speed": 3.2, + "wind_from_direction": "NE" + } + } + ] + } + ] + } +} ``` --- -## Query parameters +## Hourly Forecasting Endpoint -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `device_id` | string | Conditional | Device identifier (use either `device_id` or `site_id`) | -| `site_id` | string | Conditional | Site identifier (use either `site_id` or `device_id`) | -| `token` | string | Yes | Your SECRET TOKEN | +```http +GET /api/v2/predict/hourly-forecasting/ +``` ---- +Returns hourly PM2.5 forecasts beginning from the current forecast hour. -## Example requests +### Supported Parameters -```bash -# Hourly forecast by site ID -curl -X GET \ - "https://api.airqo.net/api/v2/predict/hourly-forecast?site_id=64f7b3e8c9d25a0013f2d456&token=YOUR_SECRET_TOKEN" +| Parameter | Type | Description | +|-----------|------|-------------| +| `site_id` | string | Forecast for a single site | +| `grid_id` | string | Forecasts for all sites within a grid | +| `cohort_id` | string | Forecasts for all sites within a cohort | +| `scope` | string | Optional — `"grid"` or `"cohort"` to disambiguate | +| `page` | integer | Page number (default: `1`) | +| `limit` | integer | Results per page (default: `10`, max: `100`) | -# Daily forecast by device ID -curl -X GET \ - "https://api.airqo.net/api/v2/predict/daily-forecast?device_id=65c8d4a2f1b45c0012a3e789&token=YOUR_SECRET_TOKEN" -``` +Only one of `site_id`, `grid_id`, or `cohort_id` may be provided per request. -**JavaScript — hourly forecast:** +### Key Features -```js -async function getHourlyForecast(siteId, token) { - const url = `https://api.airqo.net/api/v2/predict/hourly-forecast?site_id=${siteId}&token=${token}`; - const response = await fetch(url); - const data = await response.json(); +- Default forecast horizon: **240 hours (10 days)** +- Pagination support across all sites in a grid or cohort +- AQI category, advisory label, and trend message included +- Uncertainty range via `pm2_5_q10` and `pm2_5_q90` percentiles +- Meteorological context included per hour - if (data.success) { - console.log(`Retrieved ${data.forecasts.length} hourly forecasts`); +### Response Structure - // Show next 24 hours - data.forecasts.slice(0, 24).forEach(forecast => { - console.log(`${forecast.time}: PM2.5 = ${forecast.pm2_5} μg/m³`); - console.log(`Tips: ${forecast.health_tips.join(' | ')}`); - }); +```json +{ + "success": true, + "data": { + "start_timestamp": "2026-06-07T10:00:00+00:00", + "end_timestamp": "2026-06-17T10:00:00+00:00", + "hours": 240, + "total": 10, + "page": 1, + "pages": 24, + "units": { + "pm2_5": "ug/m3", + "air_temperature": "degC", + "relative_humidity": "%", + "air_pressure_at_sea_level": "hPa", + "precipitation_amount": "mm", + "cloud_area_fraction": "%", + "wind_speed": "m/s", + "wind_from_direction": "compass", + "forecast_confidence": "%" + }, + "forecasts": [ + { + "site_details": { + "site_id": "64f7b3e8c9d25a0013f2d456", + "site_name": "Kampala Central", + "site_latitude": 0.3476, + "site_longitude": 32.5825 + }, + "forecasts": [ + { + "timestamp": "2026-06-07T10:00:00+00:00", + "forecast": { + "pm2_5_mean": 28.4, + "pm2_5_q10": 20.1, + "pm2_5_q90": 36.2, + "forecast_confidence": 84.5 + }, + "aqi": { + "category": "Moderate", + "label": "Air quality is acceptable for most people.", + "trend_message": "Air pollution may increase over the next hour." + }, + "met": { + "air_temperature": 24.7, + "relative_humidity": 72.1, + "air_pressure_at_sea_level": 1012.3, + "precipitation_amount": 0.0, + "cloud_area_fraction": 45.0, + "wind_speed": 3.2, + "wind_from_direction": "NE" + } + } + ] + } + ] } - - return data; } - -getHourlyForecast('64f7b3e8c9d25a0013f2d456', 'YOUR_SECRET_TOKEN'); ``` -**Python — daily forecast:** - -```python -import requests -from datetime import datetime - -def get_daily_forecast(device_id, token): - response = requests.get( - "https://api.airqo.net/api/v2/predict/daily-forecast", - params={"device_id": device_id, "token": token} - ) - data = response.json() +--- - if data['success']: - for forecast in data['forecasts']: - date = datetime.fromisoformat(forecast['time'].replace('+00:00', '')) - print(f"\n{date.strftime('%Y-%m-%d')}: PM2.5 = {forecast['pm2_5']} μg/m³") - for tip in forecast['health_tips']: - print(f" • {tip}") +## Scope (Grid and Cohort) Forecasting - return data +When grid-based or cohort-based forecasting is requested, the API supports aggregated forecast retrieval through: -get_daily_forecast('65c8d4a2f1b45c0012a3e789', 'YOUR_SECRET_TOKEN') +```http +GET /api/v2/predict/hourly-forecasting/ +GET /api/v2/predict/daily-forecasting/ ``` ---- +The `` path parameter accepts a grid ID or cohort ID. Use the optional `scope` query parameter (`"grid"` or `"cohort"`) if the ID is ambiguous. -## Example response +### Scope Metadata Returned + +The fields below are returned inside the top-level `data` object (`{ "success": true, "data": { ... } }`). ```json { - "success": true, - "message": "Forecasts retrieved successfully", + "scope": { + "type": "grid", + "id": "64b7ac8fd7249f0029feca80", + "grid_id": "64b7ac8fd7249f0029feca80" + }, + "sites_count": 3, + "sites_with_forecasts_count": 2, + "site_names": [ + "Kampala Central", + "Makerere" + ], "forecasts": [ { - "time": "2025-09-29T14:00:00+00:00", - "pm2_5": 24.5, - "health_tips": [ - "Air quality is acceptable for most people", - "Unusually sensitive individuals should consider reducing prolonged outdoor exertion" - ] - }, - { - "time": "2025-09-29T15:00:00+00:00", - "pm2_5": 28.3, - "health_tips": [ - "Air quality is acceptable for most people", - "Consider reducing outdoor activities if you experience symptoms" + "site_details": { + "site_id": "68fca1da53b8b4001372887f", + "site_name": "Buyala 1 Mpigi", + "site_latitude": 0.313465, + "site_longitude": 32.405833 + }, + "start_date": "2026-06-03", + "end_date": "2026-06-09", + "days": 7, + "total": 7, + "forecasts": [ + { + "date": "2026-06-03", + "forecast": { + "pm2_5_mean": 24.1, + "pm2_5_low": 18.0, + "pm2_5_high": 29.5 + } + } ] } - ], - "forecast_metadata": { - "model_version": "v2.3", - "generated_at": "2025-09-29T12:00:00+00:00", - "location": { - "site_id": "64f7b3e8c9d25a0013f2d456", - "device_id": "65c8d4a2f1b45c0012a3e789", - "latitude": 0.3476, - "longitude": 32.5825 - }, - "forecast_horizon_hours": 168 - } + ] } ``` --- -## Response fields - -### Forecast object - -| Field | Description | -|-------|-------------| -| `time` | Forecast timestamp (ISO 8601 with timezone) | -| `pm2_5` | Predicted PM2.5 in μg/m³ | -| `health_tips` | Array of health recommendations for the predicted air quality level | - -### `forecast_metadata` object - -| Field | Description | -|-------|-------------| -| `model_version` | Version of the forecasting model | -| `generated_at` | When this forecast was created | -| `location.site_id` | Site the forecast applies to | -| `location.device_id` | Device the forecast applies to | -| `forecast_horizon_hours` | Total hours covered (168 = 7 days) | +## Response Field Reference + +### Daily forecast item + +| Field | Type | Description | +|-------|------|-------------| +| `date` | string | Forecast date (ISO 8601 date) | +| `forecast.pm2_5_mean` | number | Mean PM2.5 forecast in µg/m³ | +| `forecast.pm2_5_low` | number | Lower bound of forecast range | +| `forecast.pm2_5_high` | number | Upper bound of forecast range | +| `forecast.pm2_5_min` | number | Minimum predicted value across the day | +| `forecast.pm2_5_max` | number | Maximum predicted value across the day | +| `forecast.forecast_confidence` | number | Model confidence (0–100%) | +| `aqi.category` | string | AQI category name (see AQI Categories below) | +| `aqi.label` | string | Health advisory for this AQI level | +| `aqi.trend_message` | string | Expected direction of PM2.5 over the next period | +| `met.air_temperature` | number | Air temperature in °C | +| `met.relative_humidity` | number | Relative humidity in % | +| `met.air_pressure_at_sea_level` | number | Atmospheric pressure in hPa | +| `met.precipitation_amount` | number | Expected rainfall in mm | +| `met.cloud_area_fraction` | number | Cloud cover in % | +| `met.wind_speed` | number | Wind speed in m/s | +| `met.wind_from_direction` | string | Wind direction as compass point (e.g. `"NE"`, `"SW"`) | + +### Hourly forecast item + +Same as daily, with the following differences: + +| Field | Type | Description | +|-------|------|-------------| +| `timestamp` | string | Forecast hour (ISO 8601 datetime with timezone) | +| `forecast.pm2_5_q10` | number | 10th percentile — lower uncertainty bound | +| `forecast.pm2_5_q90` | number | 90th percentile — upper uncertainty bound | + +`pm2_5_low` / `pm2_5_high` are not included in hourly items; use `pm2_5_q10` / `pm2_5_q90` instead. --- -## Error responses +## AQI Categories -### 401 Unauthorised — invalid token +Forecasts include an AQI category derived from the predicted PM2.5 mean value. -```json -{ - "success": false, - "message": "Invalid authentication", - "error": "Unauthorized" -} -``` +| Category | PM2.5 range (µg/m³) | Color | +|----------|---------------------|-------| +| Good | 0.0 – 9.0 | Green (`#00e400`) | +| Moderate | 9.1 – 35.4 | Yellow (`#ffff00`) | +| Unhealthy for Sensitive Groups | 35.5 – 55.4 | Orange (`#ff7e00`) | +| Unhealthy | 55.5 – 125.4 | Red (`#ff0000`) | +| Very Unhealthy | 125.5 – 225.4 | Purple (`#8f3f97`) | +| Hazardous | 225.5+ | Maroon (`#7e0023`) | -### 403 Forbidden — not on Premium Tier +--- -```json -{ - "success": false, - "message": "Forecast access requires Premium Tier subscription", - "error": "Forbidden", - "upgrade_url": "https://analytics.airqo.net/account/subscription" -} -``` +## Validation Error -### 404 Not Found — invalid ID +Returned when more than one of `site_id`, `grid_id`, or `cohort_id` is provided in the same request. ```json { "success": false, - "message": "Device or site not found", - "error": "Not Found" + "message": "Please specify only one of site_id, grid_id, or cohort_id.", + "data": { + "forecasts": [] + } } ``` --- -## Best practices - -- **Cache forecast results** — forecasts update every 6 hours. Caching for 1–2 hours reduces API calls with minimal freshness trade-off. -- **Check `generated_at`** — use this to tell users how recent the forecast is. -- **Always display health tips** — they are the most user-actionable part of the forecast. -- **Prefer near-term data** — the first 48 hours are more accurate than days 4–7. -- **Implement graceful fallback** — if the forecast call fails, display the most recent historical reading instead. - ---- - -## Forecast availability - -| Detail | Value | -|--------|-------| -| Update frequency | Every 6 hours (00:00, 06:00, 12:00, 18:00 UTC) | -| Hourly horizon | 168 hours (7 days) | -| Daily horizon | 7 days | -| Coverage | All active monitoring sites | -| Historical forecasts | Not available | -| Pollutants | PM2.5 (PM10 in future versions) | - ---- - -## Upgrade to Premium +## Caching and Update Frequency -To unlock forecast access, log in to [analytics.airqo.net](https://analytics.airqo.net), go to **Account Settings → Subscription**, and select Premium Tier. +Forecast results are cached per request parameters. Cache is invalidated automatically when the underlying forecast data changes — typically aligned with model run cycles. For most integrations, caching responses for **1–3 hours** gives a good balance between API call volume and data freshness. See [Best Practices →](../reference/best-practices.md) for caching guidance. diff --git a/src/docs-website/docs/api/reference/error-codes.md b/src/docs-website/docs/api/reference/error-codes.md index 00995665fd..5ba11bd49b 100644 --- a/src/docs-website/docs/api/reference/error-codes.md +++ b/src/docs-website/docs/api/reference/error-codes.md @@ -5,11 +5,15 @@ sidebar_label: Error Codes # Error Codes -All AirQo API endpoints return consistent error responses so you can handle failures programmatically. +AirQo API endpoints use two different error response formats depending on the API family. Match your error handling to the endpoint you are calling. --- -## Error response format +## Error response formats + +### Devices and Analytics API + +Used by: measurement endpoints (`/api/v2/devices/...`), Analytics API (`/api/v3/public/analytics/...`), and metadata endpoints (`/api/v2/devices/metadata/...`). ```json { @@ -19,7 +23,15 @@ All AirQo API endpoints return consistent error responses so you can handle fail } ``` -For forecast endpoints, the shape uses `success: false`: +| Field | Type | Description | +|-------|------|-------------| +| `status` | string | `"error"` on failure | +| `message` | string | Human-readable description | +| `code` | string | Machine-readable error code (e.g. `"NOT_FOUND"`, `"UNAUTHORIZED"`) | + +### Forecast API + +Used by: `/api/v2/predict/daily-forecasting/` and `/api/v2/predict/hourly-forecasting/`. ```json { @@ -29,6 +41,12 @@ For forecast endpoints, the shape uses `success: false`: } ``` +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Always `false` on error | +| `message` | string | Human-readable description | +| `error` | string | HTTP status phrase (e.g. `"Unauthorized"`, `"Forbidden"`) | + --- ## HTTP status codes @@ -57,6 +75,24 @@ For forecast endpoints, the shape uses `success: false`: --- +### 400 Bad Request — conflicting forecast parameters + +```json +{ + "success": false, + "message": "Please specify only one of site_id, grid_id, or cohort_id.", + "data": { + "forecasts": [] + } +} +``` + +**Cause:** More than one of `site_id`, `grid_id`, or `cohort_id` was provided in a single forecast request. + +**Solution:** Provide exactly one identifier per request. + +--- + ### 401 Unauthorized ```json diff --git a/src/docs-website/docs/api/reference/finding-ids.md b/src/docs-website/docs/api/reference/finding-ids.md index 2abccfcb28..3d16f5417e 100644 --- a/src/docs-website/docs/api/reference/finding-ids.md +++ b/src/docs-website/docs/api/reference/finding-ids.md @@ -14,7 +14,7 @@ To use the AirQo API, you need the right identifier for your data source. This p A Cohort ID groups your organisation's devices together. Cohorts are managed by AirQo. **How to find your Cohort ID:** -- **Public Cohorts:** Browse the metadata endpoints at [docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata) +- **Public Cohorts:** Use the [Metadata API — List all cohorts](./metadata.md#list-all-public-cohorts) endpoint to browse all publicly visible cohorts - **Private Cohorts:** Contact [support@airqo.net](mailto:support@airqo.net) — your Cohort ID will be shared securely once your organisation's Cohort is set up A Cohort ID looks like: `64b9c7d5e3f82b0014c5a123` @@ -27,8 +27,9 @@ A Grid ID represents a geographical area (typically a city or district). Grids a **How to find your Grid ID:** -1. Visit the [metadata documentation](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata) -2. Use the `admin_level` query parameter to filter by country, province, or city +1. Call the [grid summary endpoint](./metadata.md#grid-summary-with-site-details): `GET /api/v2/devices/grids/summary` +2. Use the `admin_level=city` query parameter to filter to city-level grids +3. Locate your city by `long_name` and copy its `_id` value **Known Grid IDs (examples):** @@ -36,7 +37,7 @@ A Grid ID represents a geographical area (typically a city or district). Grids a |------|---------| | Nairobi | `64b7ac8fd7249f0029feca80` | -For other cities, use the metadata endpoint to browse all available Grids. +For other cities, use the [Metadata API](./metadata.md) to browse all available grids. --- @@ -45,8 +46,9 @@ For other cities, use the metadata endpoint to browse all available Grids. A Site ID identifies a specific physical monitoring location. **How to find Site IDs:** -- Call the [recent measurements endpoint](../for-cities/recent-measurements.md) for your Grid and collect the `site_id` field from each measurement -- Browse the metadata endpoints for a full list: [docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata](https://docs.airqo.net/airqo-rest-api-documentation/api-endpoints/metadata) +- Call the [grid generate endpoint](./metadata.md#get-all-site-and-device-ids-for-a-grid) (`GET /api/v2/devices/grids/{GRID_ID}/generate`) to list every site (and its devices) within a grid +- Call the [Metadata API — List all sites](./metadata.md#list-all-public-sites) to browse all public monitoring sites +- Or call the [recent measurements endpoint](../for-cities/recent-measurements.md) for your Grid and collect the `site_id` field from each measurement record A Site ID looks like: `64f7b3e8c9d25a0013f2d456` @@ -75,6 +77,23 @@ A Device ID uniquely identifies a sensor unit. Device names follow the pattern ` --- +## Browsing via the Metadata API + +The [Metadata API](./metadata.md) provides dedicated endpoints for discovering every public resource: + +| What you want | Endpoint | +|---------------|----------| +| Browse all grids | `GET /api/v2/devices/metadata/grids` | +| Browse grids by city/country | `GET /api/v2/devices/grids/summary?admin_level=city` | +| Look up one grid | `GET /api/v2/devices/metadata/grids/{GRID_ID}` | +| Sites in a grid | `GET /api/v2/devices/grids/{GRID_ID}/generate` | +| Browse all cohorts | `GET /api/v2/devices/metadata/cohorts` | +| Sites and devices in a cohort | `GET /api/v2/devices/cohorts/{COHORT_ID}/generate` | +| Browse all sites | `GET /api/v2/devices/metadata/sites` | +| Browse all devices | `GET /api/v2/devices/metadata/devices` | + +--- + ## Still can't find your ID? Contact [support@airqo.net](mailto:support@airqo.net) with your organisation's name or the city/region you are monitoring. The team can look up the correct IDs for you. diff --git a/src/docs-website/docs/api/reference/metadata.md b/src/docs-website/docs/api/reference/metadata.md new file mode 100644 index 0000000000..c1da52bb77 --- /dev/null +++ b/src/docs-website/docs/api/reference/metadata.md @@ -0,0 +1,448 @@ +--- +sidebar_position: 6 +sidebar_label: Metadata API +--- + +# Metadata API + +The Metadata API lets you browse the AirQo monitoring infrastructure — grids, cohorts, sites, devices, and geographic boundaries — so you can discover the right identifiers before querying measurement or forecast data. + +All endpoints require your `token` query parameter. See [Authentication →](../getting-started/authentication.md). + +--- + +## Grids + +Grids are geographically-defined groupings of monitoring sites, typically aligned to a city or administrative district. + +### List all grids + +```http +GET /api/v2/devices/metadata/grids?token={SECRET_TOKEN} +``` + +Returns all publicly visible grids. + +**Query parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `token` | string | — | Required | +| `limit` | integer | 1000 | Max results per page (max: 2000) | +| `skip` | integer | 0 | Records to skip | + +**Example response** + +```json +{ + "success": true, + "grids": [ + { + "_id": "64b7ac8fd7249f0029feca80", + "name": "nairobi_city", + "long_name": "Nairobi City", + "admin_level": "city", + "visibility": true, + "network": "airqo", + "createdAt": "2023-07-19T10:25:00.000Z" + } + ], + "meta": { + "total": 42, + "skip": 0, + "limit": 1000, + "page": 1, + "pages": 1 + } +} +``` + +--- + +### Grid summary with site details + +```http +GET /api/v2/devices/grids/summary?token={SECRET_TOKEN} +``` + +Returns grids with their constituent site details nested inside. This is the most useful endpoint for **discovering Grid IDs and the sites within them**. + +**Query parameters** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `token` | string | Required | +| `admin_level` | string | Filter by level: `country`, `province`, `city`, `district`, `subcounty`, `county`, `division`, `parish` | +| `id` | string | Filter by a specific Grid ID | + +**Example — filter to city-level grids** + +```bash +curl "https://api.airqo.net/api/v2/devices/grids/summary?admin_level=city&token=YOUR_SECRET_TOKEN" +``` + +**Example response** + +```json +{ + "success": true, + "grids": [ + { + "_id": "64b7ac8fd7249f0029feca80", + "name": "nairobi_city", + "long_name": "Nairobi City", + "admin_level": "city", + "visibility": true, + "network": "airqo", + "numberOfSites": 12, + "createdAt": "2023-07-19T10:25:00.000Z", + "sites": [ + { + "_id": "64f7b3e8c9d25a0013f2d456", + "name": "nairobi_road", + "long_name": "Nairobi Road, Nairobi", + "latitude": -1.2921, + "longitude": 36.8219, + "data_provider": "airqo" + } + ] + } + ] +} +``` + +--- + +### Get a single grid + +```http +GET /api/v2/devices/metadata/grids/{GRID_ID}?token={SECRET_TOKEN} +``` + +Returns full details for one grid. + +--- + +### Get all site and device IDs for a grid + +```http +GET /api/v2/devices/grids/{GRID_ID}/generate?token={SECRET_TOKEN} +``` + +Returns every site and device ID belonging to the grid. Use this to build a list of `site_id` or `device_id` values for measurement and forecast queries. + +**Example response** + +```json +{ + "success": true, + "sites": [ + { + "site_id": "64f7b3e8c9d25a0013f2d456", + "site_name": "Nairobi Road", + "devices": [ + { + "device_id": "65c8d4a2f1b45c0012a3e789", + "device_name": "airqo_bx2847" + } + ] + } + ] +} +``` + +--- + +### List countries + +```http +GET /api/v2/devices/grids/countries?token={SECRET_TOKEN} +``` + +Returns all countries that have AirQo monitoring coverage. Useful for filtering the grid summary by country before drilling down to a city. + +--- + +## Cohorts + +Cohorts are organisationally-defined groups of devices, managed by the AirQo team on behalf of partner organisations. + +### List all public cohorts + +```http +GET /api/v2/devices/metadata/cohorts?token={SECRET_TOKEN} +``` + +Returns all publicly visible cohorts. + +**Query parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `token` | string | — | Required | +| `limit` | integer | 1000 | Max results per page (max: 2000) | +| `skip` | integer | 0 | Records to skip | + +**Example response** + +```json +{ + "success": true, + "cohorts": [ + { + "_id": "64b9c7d5e3f82b0014c5a123", + "name": "partner_network", + "long_name": "Partner Monitoring Network", + "visibility": true, + "network": "airqo", + "createdAt": "2023-08-01T00:00:00.000Z" + } + ], + "meta": { + "total": 5, + "skip": 0, + "limit": 1000, + "page": 1, + "pages": 1 + } +} +``` + +:::note Private Cohorts +Private cohorts do not appear in this list. If your organisation has a private Cohort, contact [support@airqo.net](mailto:support@airqo.net) to obtain your Cohort ID. +::: + +--- + +### Get a single cohort + +```http +GET /api/v2/devices/metadata/cohorts/{COHORT_ID}?token={SECRET_TOKEN} +``` + +Returns full details for one cohort. + +--- + +### Get all site and device IDs for a cohort + +```http +GET /api/v2/devices/cohorts/{COHORT_ID}/generate?token={SECRET_TOKEN} +``` + +Returns every site and device ID belonging to the cohort. The structure matches the grid generate endpoint. + +**Example response** + +```json +{ + "success": true, + "sites": [ + { + "site_id": "64f7b3e8c9d25a0013f2d456", + "site_name": "Kampala Central", + "devices": [ + { + "device_id": "65c8d4a2f1b45c0012a3e789", + "device_name": "airqo_bx2847" + } + ] + } + ] +} +``` + +--- + +## Sites + +Sites are fixed physical locations where sensors are deployed. + +### List all public sites + +```http +GET /api/v2/devices/metadata/sites?token={SECRET_TOKEN} +``` + +Returns all publicly visible monitoring sites. + +**Query parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `token` | string | — | Required | +| `limit` | integer | 1000 | Max results per page (max: 2000) | +| `skip` | integer | 0 | Records to skip | + +**Example response** + +```json +{ + "success": true, + "sites": [ + { + "_id": "64f7b3e8c9d25a0013f2d456", + "name": "kampala_road", + "long_name": "Kampala Road, Kampala", + "generated_name": "Kampala Road", + "latitude": 0.3476, + "longitude": 32.5825, + "network": "airqo", + "visibility": true, + "online_status": "online" + } + ], + "meta": { + "total": 250, + "skip": 0, + "limit": 1000, + "page": 1, + "pages": 1 + } +} +``` + +--- + +### Get a single site + +```http +GET /api/v2/devices/metadata/sites/{SITE_ID}?token={SECRET_TOKEN} +``` + +Returns full details for one monitoring site, including coordinates, administrative context, and current online status. + +--- + +## Devices + +Devices are individual sensor units. Each device is deployed at a site. + +### List all public devices + +```http +GET /api/v2/devices/metadata/devices?token={SECRET_TOKEN} +``` + +Returns all publicly visible devices. + +**Query parameters** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `token` | string | — | Required | +| `limit` | integer | 1000 | Max results per page (max: 2000) | +| `skip` | integer | 0 | Records to skip | + +**Example response** + +```json +{ + "success": true, + "devices": [ + { + "_id": "65c8d4a2f1b45c0012a3e789", + "name": "airqo_bx2847", + "category": "lowcost", + "status": "deployed", + "site_id": "64f7b3e8c9d25a0013f2d456", + "network": "airqo", + "isOnline": true, + "lastActive": "2025-09-28T08:45:00.000Z" + } + ], + "meta": { + "total": 850, + "skip": 0, + "limit": 1000, + "page": 1, + "pages": 1 + } +} +``` + +**Device categories** + +| Category | Description | +|----------|-------------| +| `lowcost` | Low-cost optical particle sensors (the majority of AirQo devices) | +| `bam` | Beta Attenuation Monitor — reference-grade instruments | +| `gas` | Gas sensor units | + +--- + +### Get a single device + +```http +GET /api/v2/devices/metadata/devices/{DEVICE_ID}?token={SECRET_TOKEN} +``` + +Returns full details for one device, including its deployment site and online status. + +--- + +## Locations + +The locations endpoint lets you browse administrative boundaries at different geographic levels — useful for understanding what areas AirQo covers before querying a grid. + +### List locations + +```http +GET /api/v2/devices/locations?token={SECRET_TOKEN} +``` + +**Query parameters** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `token` | string | Required | +| `admin_level` | string | Filter by level: `country`, `province`, `state`, `district`, `county`, `subcounty`, `division`, `parish`, `village` | +| `name` | string | Filter by location name (partial match) | +| `limit` | integer | Max results per page | +| `skip` | integer | Records to skip | + +**Example — list all countries** + +```bash +curl "https://api.airqo.net/api/v2/devices/locations?admin_level=country&token=YOUR_SECRET_TOKEN" +``` + +--- + +## Pagination + +All list endpoints support `limit` and `skip` parameters for pagination. The `meta` object in every response includes `total`, `page`, and `pages` to help you navigate large result sets. + +**Example — page through all sites in blocks of 100** + +```bash +# Page 1 +curl "https://api.airqo.net/api/v2/devices/metadata/sites?limit=100&skip=0&token=YOUR_SECRET_TOKEN" + +# Page 2 +curl "https://api.airqo.net/api/v2/devices/metadata/sites?limit=100&skip=100&token=YOUR_SECRET_TOKEN" +``` + +--- + +## Common workflows + +### Find a Grid ID for a city + +1. Call the grid summary endpoint with `admin_level=city` +2. Locate your city by `long_name` +3. Copy the `_id` value — that is your Grid ID + +### Find all Site IDs in your Grid + +1. Call `GET /api/v2/devices/grids/{GRID_ID}/generate` +2. Collect the `site_id` from each entry in the `sites` array + +### Find all Device IDs in your Cohort + +1. Call `GET /api/v2/devices/cohorts/{COHORT_ID}/generate` +2. For each site, collect `device_id` values from the nested `devices` array + +--- + +See also: [Finding IDs →](./finding-ids.md) diff --git a/src/docs-website/docs/api/reference/response-structure.md b/src/docs-website/docs/api/reference/response-structure.md index 51178e310f..2b45ddda8a 100644 --- a/src/docs-website/docs/api/reference/response-structure.md +++ b/src/docs-website/docs/api/reference/response-structure.md @@ -190,28 +190,36 @@ Used by: `/api/v2/spatial/heatmaps` endpoints. ## Forecast response -Used by: `/api/v2/predict/hourly-forecast` and `/api/v2/predict/daily-forecast`. +Used by: `/api/v2/predict/daily-forecasting/` and `/api/v2/predict/hourly-forecasting/`. + +See [Forecast API →](../forecasts/overview.md) for the full response schema, field reference, and scope-based variants. + +**Daily forecasting — abbreviated example:** ```json { "success": true, - "message": "Forecasts retrieved successfully", - "forecasts": [ - { - "time": "2025-09-29T14:00:00+00:00", - "pm2_5": 24.5, - "health_tips": ["Air quality is acceptable for most people"] - } - ], - "forecast_metadata": { - "model_version": "v2.3", - "generated_at": "2025-09-29T12:00:00+00:00", - "location": { - "site_id": "64f7b3e8c9d25a0013f2d456", - "latitude": 0.3476, - "longitude": 32.5825 - }, - "forecast_horizon_hours": 168 + "data": { + "start_date": "2026-06-03", + "end_date": "2026-06-09", + "days": 7, + "total": 1, + "units": { "pm2_5": "ug/m3", "forecast_confidence": "%" }, + "forecasts": [ + { + "site_details": { "site_id": "64f7b3e8c9d25a0013f2d456", "site_name": "Kampala Central" }, + "forecasts": [ + { + "date": "2026-06-03", + "forecast": { "pm2_5_mean": 28.4, "forecast_confidence": 84.5 }, + "aqi": { "category": "Moderate", "label": "Air quality is acceptable for most people." }, + "met": { "air_temperature": 24.7, "relative_humidity": 72.1 } + } + ] + } + ] } } ``` + +**Hourly forecasting** uses the same envelope but with `start_timestamp`, `end_timestamp`, `hours`, and pagination fields (`page`, `pages`). Individual forecast items use `timestamp` instead of `date` and include `pm2_5_q10` / `pm2_5_q90` uncertainty bounds instead of `pm2_5_low` / `pm2_5_high`. diff --git a/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart b/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart index 822626efe7..32e16b9bfb 100644 --- a/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart +++ b/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart @@ -263,18 +263,21 @@ class _DashboardPageState extends State with UiLoggy { } if (state is DashboardLoadingError && !state.hasCache) { + final isOffline = state.isOffline; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.cloud_off, + isOffline ? Icons.cloud_off : Icons.error_outline, size: 64, color: Colors.grey, ), SizedBox(height: 16), TranslatedText( - "Couldn't connect to the internet", + isOffline + ? "Couldn't connect to the internet" + : "Couldn't load air quality data", style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -283,7 +286,9 @@ class _DashboardPageState extends State with UiLoggy { ), SizedBox(height: 8), TranslatedText( - "Please check your connection and try again", + isOffline + ? "Please check your connection and try again" + : "Something went wrong. Please try again later", style: TextStyle( fontSize: 16, color: Theme.of(context).textTheme.bodyMedium?.color, diff --git a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart index beb52611b4..68a13c470f 100644 --- a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart +++ b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart @@ -1,5 +1,6 @@ import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; import 'package:airqo/src/app/learn/repository/kya_repository.dart'; +import 'package:airqo/src/app/shared/services/cache_manager.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:loggy/loggy.dart'; @@ -9,6 +10,8 @@ part 'kya_state.dart'; class KyaBloc extends Bloc with UiLoggy { final KyaRepository repository; + final CacheManager _cacheManager = CacheManager(); + KyaBloc(this.repository) : super(KyaInitial()) { on(_onLoadLessons); on(_onRefreshLessons); @@ -32,10 +35,14 @@ class KyaBloc extends Bloc with UiLoggy { emit(LessonsLoadingError( message: e.toString(), cachedModel: cachedModel, + isOffline: !_cacheManager.isConnected, )); } catch (cacheError) { loggy.error('Error fetching cached lessons: $cacheError'); - emit(LessonsLoadingError(message: e.toString())); + emit(LessonsLoadingError( + message: e.toString(), + isOffline: !_cacheManager.isConnected, + )); } } } @@ -62,6 +69,7 @@ class KyaBloc extends Bloc with UiLoggy { emit(LessonsLoadingError( message: e.toString(), cachedModel: cachedModel, + isOffline: !_cacheManager.isConnected, )); } } diff --git a/src/mobile/lib/src/app/learn/bloc/kya_state.dart b/src/mobile/lib/src/app/learn/bloc/kya_state.dart index de5c8a01ca..71bb64c556 100644 --- a/src/mobile/lib/src/app/learn/bloc/kya_state.dart +++ b/src/mobile/lib/src/app/learn/bloc/kya_state.dart @@ -33,12 +33,14 @@ class LessonsLoaded extends KyaState { class LessonsLoadingError extends KyaState { final String message; final LessonResponseModel? cachedModel; - + final bool isOffline; + const LessonsLoadingError({ required this.message, this.cachedModel, + this.isOffline = false, }); - + @override - List get props => [message, cachedModel]; + List get props => [message, cachedModel, isOffline]; } \ No newline at end of file diff --git a/src/mobile/lib/src/app/learn/pages/kya_page.dart b/src/mobile/lib/src/app/learn/pages/kya_page.dart index 5cc2edc755..e5834122e4 100644 --- a/src/mobile/lib/src/app/learn/pages/kya_page.dart +++ b/src/mobile/lib/src/app/learn/pages/kya_page.dart @@ -198,7 +198,11 @@ class _KyaPageState extends State with UiLoggy { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.cloud_off, size: 64, color: Colors.grey), + Icon( + state.isOffline ? Icons.cloud_off : Icons.error_outline, + size: 64, + color: Colors.grey, + ), const SizedBox(height: 16), TranslatedText( "Unable to load content", @@ -212,7 +216,9 @@ class _KyaPageState extends State with UiLoggy { Padding( padding: const EdgeInsets.symmetric(horizontal: 32.0), child: TranslatedText( - "Please check your connection and try again", + state.isOffline + ? "Please check your connection and try again" + : "Something went wrong. Please try again later", textAlign: TextAlign.center, style: TextStyle( fontSize: 16, diff --git a/src/mobile/lib/src/app/map/pages/map_page.dart b/src/mobile/lib/src/app/map/pages/map_page.dart index 1d129e5f02..a15d97c78d 100644 --- a/src/mobile/lib/src/app/map/pages/map_page.dart +++ b/src/mobile/lib/src/app/map/pages/map_page.dart @@ -13,6 +13,7 @@ import 'package:airqo/src/app/map/widgets/map_overlay_controls.dart'; import 'package:airqo/src/app/map/widgets/map_search_sheet.dart'; import 'package:airqo/src/app/map/widgets/map_style_picker.dart'; import 'package:airqo/src/app/other/places/bloc/google_places_bloc.dart'; +import 'package:airqo/src/app/shared/services/cache_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -348,6 +349,8 @@ class _MapScreenState extends State (markers.isEmpty || allMeasurements.isEmpty)) { _initializeWithData(response); } + } else if (state is MapLoadingError) { + if (mounted) setState(() => isInitializing = false); } }, ), @@ -383,7 +386,7 @@ class _MapScreenState extends State markers.isEmpty && allMeasurements.isEmpty && !isRetrying) - ? MapErrorView(onRetry: _retryLoading) + ? MapErrorView(onRetry: _retryLoading, isOffline: !CacheManager().isConnected) : _buildMapView(), ), ); diff --git a/src/mobile/lib/src/app/map/widgets/map_error_view.dart b/src/mobile/lib/src/app/map/widgets/map_error_view.dart index 4594449190..dffbbdd1f9 100644 --- a/src/mobile/lib/src/app/map/widgets/map_error_view.dart +++ b/src/mobile/lib/src/app/map/widgets/map_error_view.dart @@ -4,8 +4,9 @@ import 'package:flutter/material.dart'; class MapErrorView extends StatelessWidget { final VoidCallback onRetry; + final bool isOffline; - const MapErrorView({super.key, required this.onRetry}); + const MapErrorView({super.key, required this.onRetry, this.isOffline = false}); @override Widget build(BuildContext context) { @@ -13,7 +14,7 @@ class MapErrorView extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.map_outlined, size: 64, color: Colors.grey), + Icon(isOffline ? Icons.cloud_off : Icons.map_outlined, size: 64, color: Colors.grey), const SizedBox(height: 16), TranslatedText( "Unable to load map data", @@ -24,7 +25,9 @@ class MapErrorView extends StatelessWidget { ), const SizedBox(height: 8), TranslatedText( - "Please check your connection and try again", + isOffline + ? "Please check your connection and try again" + : "Something went wrong. Please try again later", style: TextStyle( fontSize: 16, color: Theme.of(context).textTheme.bodyMedium?.color), diff --git a/src/mobile/lib/src/app/profile/pages/languages/select_language_page.dart b/src/mobile/lib/src/app/profile/pages/languages/select_language_page.dart index 702cb53472..f28cabf752 100644 --- a/src/mobile/lib/src/app/profile/pages/languages/select_language_page.dart +++ b/src/mobile/lib/src/app/profile/pages/languages/select_language_page.dart @@ -70,7 +70,7 @@ class _SelectLanguagePageState extends State { if (needsMlKitDownload) { await MlKitTranslationService() .prepareModel(language.code) - .timeout(const Duration(seconds: 30)); + .timeout(const Duration(minutes: 3)); await MlKitTranslationService() .prepareCriticalStrings(language.code) .timeout(const Duration(seconds: 30)); @@ -88,10 +88,12 @@ class _SelectLanguagePageState extends State { debugPrint('Language preparation failed: $e\n$stackTrace'); if (!mounted) return; setState(() => _preparingCode = null); - final isNetworkError = e is SocketException || e is TimeoutException; + final isNetworkError = e is SocketException; final message = isNetworkError ? 'No internet connection. Please check your network and try again.' - : 'Failed to prepare language. Please try again.'; + : e is TimeoutException + ? 'Download is taking too long. Please check your connection and try again.' + : 'Failed to prepare language. Please try again.'; messenger.showSnackBar( SnackBar( content: Text(message), diff --git a/src/platform/docs/DOWNLOAD_FEATURE_OPTIMIZATION.md b/src/platform/docs/DOWNLOAD_FEATURE_OPTIMIZATION.md index 0788040c51..8af9d43757 100644 --- a/src/platform/docs/DOWNLOAD_FEATURE_OPTIMIZATION.md +++ b/src/platform/docs/DOWNLOAD_FEATURE_OPTIMIZATION.md @@ -37,17 +37,20 @@ Added new utility methods to handle timezone-aware date formatting: **File**: `src/modules/analytics/components/DownloadDialog.tsx` **Memory Leak Prevention:** + - Added `useCallback` for `handleConfirmDownload` with proper dependencies - Added `useCallback` for `handleDataTypeChange` to prevent unnecessary re-renders - Memoized `frequencyOptions` and `pollutantOptions` to prevent recalculation - Memoized `siteDisplayData` to optimize site list rendering **Improved Error Handling:** + - Removed date formatting logic since dates come pre-formatted from FilterBar - Enhanced error messages with proper toast notifications - Added try-catch with specific error handling using `getUserFriendlyErrorMessage` **Code Optimization:** + - Removed duplicate code and unnecessary date manipulations - Simplified date handling by using pre-formatted dates from filters - Improved component performance with memoization @@ -55,12 +58,14 @@ Added new utility methods to handle timezone-aware date formatting: ### 5. Fixed Backend Integration Issues **DateTime Format Issue Fixed:** + - Backend now receives timezone-aware dates in format: `"2025-07-20T00:00:00.000Z"` - Start dates set to 00:00:00.000 (beginning of day) - End dates set to 23:59:59.999 (end of day) - All dates include proper timezone information (`Z` suffix for UTC) **Request Payload:** + - Added required `device_category: 'lowcost'` field - Ensured all required fields are properly formatted - Dates are passed directly without additional processing @@ -90,9 +95,9 @@ The download request now sends dates in the exact format expected by the backend { "startDateTime": "2025-07-20T00:00:00.000Z", "endDateTime": "2025-07-28T23:59:59.999Z", - "device_category": "lowcost", + "device_category": "lowcost" // ... other required fields } ``` -This should resolve the "startDateTime must be timezone-aware" error from the backend. \ No newline at end of file +This should resolve the "startDateTime must be timezone-aware" error from the backend. diff --git a/src/platform/docs/SWR_INTEGRATION_README.md b/src/platform/docs/SWR_INTEGRATION_README.md index d78f96f5fb..ba9dd84d23 100644 --- a/src/platform/docs/SWR_INTEGRATION_README.md +++ b/src/platform/docs/SWR_INTEGRATION_README.md @@ -15,7 +15,12 @@ SWR provides powerful caching, revalidation, and synchronization capabilities fo ### Authentication Mutations ```typescript -import { useLogin, useRegister, useForgotPassword, useResetPassword } from '@/shared/hooks/useAuth'; +import { + useLogin, + useRegister, + useForgotPassword, + useResetPassword, +} from '@/shared/hooks/useAuth'; // Login const { trigger: login, isMutating: isLoggingIn } = useLogin(); @@ -28,7 +33,7 @@ await register({ lastName: 'Doe', email: 'john@example.com', password: 'password', - category: 'individual' + category: 'individual', }); // Forgot Password @@ -171,4 +176,4 @@ The SWR hooks work seamlessly with NextAuth: ## Example Component -See `src/shared/components/AuthExample.tsx` for a complete working example of all hooks in action. \ No newline at end of file +See `src/shared/components/AuthExample.tsx` for a complete working example of all hooks in action. diff --git a/src/platform/src/app/(dashboard)/request-organization/page.tsx b/src/platform/src/app/(dashboard)/request-organization/page.tsx index 375ab162c4..96d2a575b0 100644 --- a/src/platform/src/app/(dashboard)/request-organization/page.tsx +++ b/src/platform/src/app/(dashboard)/request-organization/page.tsx @@ -61,7 +61,9 @@ const INITIAL_FORM: FormData = { const RequestOrganizationPage = () => { const [formData, setFormData] = useState(INITIAL_FORM); - const [validationErrors, setValidationErrors] = useState({}); + const [validationErrors, setValidationErrors] = useState( + {} + ); const [showSuccessDialog, setShowSuccessDialog] = useState(false); const { trigger: createRequest, isMutating: isCreating } = diff --git a/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx b/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx index d9158bed44..948668674e 100644 --- a/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/clients/[clientId]/page.tsx @@ -6,8 +6,10 @@ import { Button, Card, PageHeading } from '@/shared/components/ui'; import { Tooltip } from 'flowbite-react'; import { LoadingState } from '@/shared/components/ui/loading-state'; import { toast } from '@/shared/components/ui'; +import { WarningBanner } from '@/shared/components/ui/banner'; import { formatDate, parseDate } from '@/shared/utils'; import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging'; import { AqArrowLeft, AqEdit05, @@ -22,6 +24,7 @@ import useSWR from 'swr'; import Dialog from '@/shared/components/ui/dialog'; import EditClientDialog from '@/modules/api-client/components/EditClientDialog'; import InactiveClientDialog from '@/modules/api-client/components/InactiveClientDialog'; +import TokenSecurityDialog from '@/modules/api-client/components/TokenSecurityDialog'; import { PermissionGuard } from '@/shared/components/PermissionGuard'; import { useRBAC, useUser } from '@/shared/hooks'; @@ -40,10 +43,12 @@ const ClientDetailsPage: React.FC = () => { activate: true, }); const [generateTokenDialogOpen, setGenerateTokenDialogOpen] = useState(false); + const [tokenSecurityDialogOpen, setTokenSecurityDialogOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isRefreshingSecret, setIsRefreshingSecret] = useState(false); const [isActivating, setIsActivating] = useState(false); const [isGeneratingToken, setIsGeneratingToken] = useState(false); + const [isReinstatingToken, setIsReinstatingToken] = useState(false); const [inactiveDialogState, setInactiveDialogState] = useState<{ isOpen: boolean; clientId: string; @@ -90,6 +95,14 @@ const ClientDetailsPage: React.FC = () => { : '—'; const requiresClientSecret = Boolean(client?.requireClientSecret); + const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const describeAllowedDays = (days: number[] | undefined) => { + if (!days || days.length === 0) { + return 'All days'; + } + + return days.map(day => dayLabels[day] || String(day)).join(', '); + }; const handleBack = () => { router.push('/system/clients'); @@ -181,6 +194,34 @@ const ClientDetailsPage: React.FC = () => { } }; + const handleOpenTokenSecurity = () => { + if (!token) { + toast.error('Token data is unavailable for this client'); + return; + } + + setTokenSecurityDialogOpen(true); + }; + + const handleReinstateToken = async () => { + if (!token?.token) { + toast.error('Token data is unavailable for this client'); + return; + } + + setIsReinstatingToken(true); + try { + await clientService.reinstateToken(token.token); + toast.success('Token reinstated successfully'); + mutate(); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + console.error('Reinstate token error:', sanitizeErrorForLogging(error)); + } finally { + setIsReinstatingToken(false); + } + }; + const copyToClipboard = async (text: string, label: string) => { try { await navigator.clipboard.writeText(text); @@ -571,6 +612,182 @@ const ClientDetailsPage: React.FC = () => { + {token && ( + +
+
+

Token Security

+

+ Review grid, cohort, origin, and schedule restrictions for + this token. +

+
+ +
+ +
+ {token.request_pattern?.auto_suspended && ( + +

+ This token was suspended because suspicious activity was + detected. +

+ {token.request_pattern.suspension_reason && ( +

+ Reason:{' '} + {token.request_pattern.suspension_reason} +

+ )} + {token.request_pattern.suspended_at && ( +

+ Suspended at:{' '} + {formatDate(token.request_pattern.suspended_at, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

+ )} +
+ } + actions={ + + } + /> + )} + +
+
+

+ Grid restrictions +

+
+ {token.allowed_grids && token.allowed_grids.length > 0 ? ( + token.allowed_grids.map(grid => ( + + {grid} + + )) + ) : ( + + Unrestricted + + )} +
+
+ +
+

+ Cohort restrictions +

+
+ {token.allowed_cohorts && + token.allowed_cohorts.length > 0 ? ( + token.allowed_cohorts.map(cohort => ( + + {cohort} + + )) + ) : ( + + Unrestricted + + )} +
+
+ +
+

+ Allowed origins +

+
+ {token.allowed_origins && + token.allowed_origins.length > 0 ? ( + token.allowed_origins.map(origin => ( + + {origin} + + )) + ) : ( + + Unrestricted + + )} +
+
+ +
+

+ Access schedule +

+
+ {token.access_schedule?.enabled ? ( + <> +

+ Days:{' '} + {describeAllowedDays( + token.access_schedule.allowed_days + )} +

+ {token.access_schedule.allowed_hours_utc && + typeof token.access_schedule.allowed_hours_utc.start === + 'number' && + typeof token.access_schedule.allowed_hours_utc.end === + 'number' ? ( +

+ Hours UTC:{' '} + {String( + token.access_schedule.allowed_hours_utc.start + ).padStart(2, '0')} + :00 to{' '} + {String( + token.access_schedule.allowed_hours_utc.end + ).padStart(2, '0')} + :00 +

+ ) : ( +

Hours UTC: All day

+ )} + + ) : ( +

Disabled

+ )} +
+
+
+ +
+ )} + {/* Edit Dialog */} { }} /> + {client.access_token && ( + setTokenSecurityDialogOpen(false)} + token={client.access_token} + clientName={client.name} + onSuccess={() => { + setTokenSecurityDialogOpen(false); + mutate(); + }} + /> + )} + {/* Inactive Client Dialog */} { }; const handleRefresh = useCallback(async () => { - await mutate(); + try { + await refreshWithToast(() => mutate(), 'Clients refreshed successfully'); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + console.error('Refresh clients error:', error); + } }, [mutate]); const renderStatus = useCallback((value: unknown, item: TableClient) => { diff --git a/src/platform/src/app/(dashboard)/system/email-configs/page.tsx b/src/platform/src/app/(dashboard)/system/email-configs/page.tsx new file mode 100644 index 0000000000..b4414590d5 --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/email-configs/page.tsx @@ -0,0 +1,789 @@ +'use client'; + +import React, { useCallback, useMemo, useState } from 'react'; +import useSWR from 'swr'; +import { Tooltip } from 'flowbite-react'; +import { + AqCopy06, + AqEdit05, + AqMail04, + AqPlus, + AqRefreshCw05, + AqTrash01, +} from '@airqo/icons-react'; +import { PermissionGuard } from '@/shared/components'; +import { + Button, + Card, + EmptyState, + Input, + LoadingState, + PageHeading, + Select, + TextInput, + toast, +} from '@/shared/components/ui'; +import Dialog from '@/shared/components/ui/dialog'; +import { ServerSideTable } from '@/shared/components/ui/server-side-table'; +import { useUser } from '@/shared/hooks'; +import { applicationEmailConfigService } from '@/shared/services'; +import type { ApplicationEmailConfiguration } from '@/shared/types/api'; +import { formatDate } from '@/shared/utils'; +import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging'; + +type EmailConfigMode = 'replace' | 'add' | 'remove'; + +type EmailConfigFormState = { + adminCCEmails: string; + applicationEmails: string; + mode: EmailConfigMode; +}; + +type EmailConfigRow = ApplicationEmailConfiguration & { + id: string; + [key: string]: unknown; +}; + +const EMAIL_SPLIT_REGEX = /[,\n;]+/; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i; + +const DEFAULT_FORM_STATE: EmailConfigFormState = { + adminCCEmails: '', + applicationEmails: '', + mode: 'replace', +}; + +const parseEmailList = (value: string): string[] => { + const seen = new Set(); + + return value + .split(EMAIL_SPLIT_REGEX) + .map(entry => entry.trim().toLowerCase()) + .filter(Boolean) + .filter(entry => { + if (seen.has(entry)) { + return false; + } + + seen.add(entry); + return true; + }); +}; + +const findInvalidEmails = (emails: string[]): string[] => { + return emails.filter(email => !EMAIL_REGEX.test(email)); +}; + +const formatEmailInput = (emails: string[]): string => { + return emails.join(', '); +}; + +const formatEmailTextarea = (emails: string[]): string => { + return emails.join('\n'); +}; + +const shortenId = (value: string): string => { + if (value.length <= 12) { + return value; + } + + return `${value.slice(0, 6)}…${value.slice(-4)}`; +}; + +const renderEmailChips = (emails: string[], emptyLabel: string) => { + if (!emails.length) { + return {emptyLabel}; + } + + const visibleEmails = emails.slice(0, 3); + const remainingCount = emails.length - visibleEmails.length; + + return ( +
+ {visibleEmails.map(email => ( + + {email} + + ))} + {remainingCount > 0 && ( + + +{remainingCount} more + + )} +
+ ); +}; + +const EmailConfigContent: React.FC = () => { + const { user } = useUser(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [dialogState, setDialogState] = useState<{ + mode: 'create' | 'edit'; + config: ApplicationEmailConfiguration | null; + } | null>(null); + const [deleteConfig, setDeleteConfig] = + useState(null); + const [formState, setFormState] = + useState(DEFAULT_FORM_STATE); + + const { + data: response, + error, + isLoading, + mutate, + } = useSWR( + '/users/application-email-configs', + () => applicationEmailConfigService.getApplicationEmailConfigurations(), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + const configs = useMemo( + () => response?.applicationEmailConfigurations || [], + [response] + ); + + const adminCCRecipientCount = useMemo(() => { + return configs.reduce( + (count, config) => count + parseEmailList(config.adminCCEmails).length, + 0 + ); + }, [configs]); + + const applicationEmailCount = useMemo(() => { + return configs.reduce( + (count, config) => count + (config.applicationEmails?.length || 0), + 0 + ); + }, [configs]); + + const configsWithApplications = useMemo(() => { + return configs.filter(config => (config.applicationEmails || []).length > 0) + .length; + }, [configs]); + + const tableData = useMemo( + () => + configs.map(config => ({ + ...config, + id: config._id, + })), + [configs] + ); + + const openCreateDialog = useCallback(() => { + setFormState(DEFAULT_FORM_STATE); + setDialogState({ + mode: 'create', + config: null, + }); + }, []); + + const openEditDialog = useCallback( + (config: ApplicationEmailConfiguration) => { + setFormState({ + adminCCEmails: config.adminCCEmails || '', + applicationEmails: formatEmailTextarea(config.applicationEmails || []), + mode: 'replace', + }); + setDialogState({ + mode: 'edit', + config, + }); + }, + [] + ); + + const closeDialog = useCallback(() => { + setDialogState(null); + setFormState(DEFAULT_FORM_STATE); + }, []); + + const closeDeleteDialog = useCallback(() => { + setDeleteConfig(null); + }, []); + + const handleRefresh = useCallback(async () => { + await mutate(); + }, [mutate]); + + const handleCopyConfigId = useCallback(async (configId: string) => { + try { + await navigator.clipboard.writeText(configId); + toast.success('Configuration ID copied to clipboard'); + } catch { + toast.error('Failed to copy configuration ID'); + } + }, []); + + const handleSubmit = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + + if (!dialogState) { + return; + } + + const rawAdminCCEmails = formState.adminCCEmails.trim(); + const parsedAdminCCEmails = rawAdminCCEmails + ? parseEmailList(rawAdminCCEmails) + : []; + const invalidAdminEmails = findInvalidEmails(parsedAdminCCEmails); + + if (dialogState.mode === 'create' && !parsedAdminCCEmails.length) { + toast.error('Add at least one valid admin CC email'); + return; + } + + if (rawAdminCCEmails && invalidAdminEmails.length > 0) { + toast.error( + `Invalid admin CC email${invalidAdminEmails.length > 1 ? 's' : ''}: ${invalidAdminEmails.join(', ')}` + ); + return; + } + + const rawApplicationEmails = formState.applicationEmails.trim(); + const parsedApplicationEmails = rawApplicationEmails + ? parseEmailList(rawApplicationEmails) + : []; + const invalidApplicationEmails = findInvalidEmails( + parsedApplicationEmails + ); + + if (rawApplicationEmails && invalidApplicationEmails.length > 0) { + toast.error( + `Invalid application email${invalidApplicationEmails.length > 1 ? 's' : ''}: ${invalidApplicationEmails.join(', ')}` + ); + return; + } + + if ( + dialogState.mode === 'edit' && + (formState.mode === 'add' || formState.mode === 'remove') && + !parsedApplicationEmails.length + ) { + toast.error('Add at least one application email to update'); + return; + } + + const payload: { + adminCCEmails?: string; + applicationEmails?: string[]; + addApplicationEmails?: string[]; + removeApplicationEmails?: string[]; + } = {}; + + if (parsedAdminCCEmails.length > 0) { + payload.adminCCEmails = formatEmailInput(parsedAdminCCEmails); + } + + if (dialogState.mode === 'create') { + if (parsedApplicationEmails.length > 0) { + payload.applicationEmails = parsedApplicationEmails; + } + + setIsSubmitting(true); + try { + const result = + await applicationEmailConfigService.createApplicationEmailConfiguration( + { + adminCCEmails: + payload.adminCCEmails || + formatEmailInput(parsedAdminCCEmails), + applicationEmails: payload.applicationEmails, + } + ); + toast.success( + result.message || 'Email configuration created successfully' + ); + closeDialog(); + await mutate(); + } catch (submitError) { + toast.error(getUserFriendlyErrorMessage(submitError)); + console.error( + 'Create email configuration error:', + sanitizeErrorForLogging(submitError) + ); + } finally { + setIsSubmitting(false); + } + return; + } + + if (!dialogState.config) { + return; + } + + if (formState.mode === 'replace') { + payload.applicationEmails = parsedApplicationEmails; + } else if (formState.mode === 'add') { + payload.addApplicationEmails = parsedApplicationEmails; + } else if (formState.mode === 'remove') { + payload.removeApplicationEmails = parsedApplicationEmails; + } + + setIsSubmitting(true); + try { + const result = + await applicationEmailConfigService.updateApplicationEmailConfiguration( + dialogState.config._id, + payload + ); + toast.success( + result.message || 'Email configuration updated successfully' + ); + closeDialog(); + await mutate(); + } catch (submitError) { + toast.error(getUserFriendlyErrorMessage(submitError)); + console.error( + 'Update email configuration error:', + sanitizeErrorForLogging(submitError) + ); + } finally { + setIsSubmitting(false); + } + }, + [closeDialog, dialogState, formState, mutate] + ); + + const handleDeleteConfig = useCallback(async () => { + if (!deleteConfig) { + return; + } + + setIsDeleting(true); + try { + const result = + await applicationEmailConfigService.deleteApplicationEmailConfiguration( + deleteConfig._id + ); + toast.success(result.message || 'Email configuration deleted'); + closeDeleteDialog(); + await mutate(); + } catch (deleteError) { + toast.error(getUserFriendlyErrorMessage(deleteError)); + console.error( + 'Delete email configuration error:', + sanitizeErrorForLogging(deleteError) + ); + } finally { + setIsDeleting(false); + } + }, [closeDeleteDialog, deleteConfig, mutate]); + + const summaryCards = useMemo( + () => [ + { + title: 'Configurations', + value: configs.length, + description: 'Saved application email config records', + }, + { + title: 'Admin CC recipients', + value: adminCCRecipientCount, + description: 'Admins copied on automated alerts', + }, + { + title: 'Application emails', + value: applicationEmailCount, + description: 'App-account emails currently tracked', + }, + { + title: 'Non-empty configs', + value: configsWithApplications, + description: 'Configs with at least one application email', + }, + ], + [ + adminCCRecipientCount, + applicationEmailCount, + configs.length, + configsWithApplications, + ] + ); + + const columns = useMemo( + () => [ + { + key: '_id', + label: 'Configuration', + minWidth: '220px', + render: (_value: unknown, item: EmailConfigRow) => ( +
+
+

+ Email config +

+

+ {shortenId(item._id)} +

+
+ + + +
+ ), + }, + { + key: 'adminCCEmails', + label: 'Admin CC emails', + minWidth: '260px', + render: (_value: unknown, item: EmailConfigRow) => + renderEmailChips( + parseEmailList(item.adminCCEmails || ''), + 'No admin CC emails' + ), + }, + { + key: 'applicationEmails', + label: 'Application emails', + minWidth: '280px', + render: (_value: unknown, item: EmailConfigRow) => + renderEmailChips( + item.applicationEmails || [], + 'No application emails' + ), + }, + { + key: 'updatedAt', + label: 'Updated', + minWidth: '160px', + cellClassName: 'whitespace-nowrap', + render: (_value: unknown, item: EmailConfigRow) => { + const updatedValue = item.updatedAt || item.createdAt || ''; + + if (!updatedValue) { + return -; + } + + return ( + + {formatDate(updatedValue, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + + ); + }, + }, + { + key: 'actions', + label: 'Actions', + minWidth: '108px', + cellClassName: 'whitespace-nowrap', + render: (_value: unknown, item: EmailConfigRow) => ( +
+ + + + + + +
+ ), + }, + ], + [handleCopyConfigId, openEditDialog] + ); + + const pageAction = ( +
+ + +
+ ); + + return ( + !!user?.email?.toLowerCase().endsWith('@airqo.net')} + accessDeniedTitle="Access Restricted" + accessDeniedMessage="You need the SUPER_ADMIN permission and an @airqo.net super-admin account to manage email configs." + > +
+ + + {isLoading ? ( + + ) : error ? ( + +
+
+

+ Failed to load email configurations +

+

+ {getUserFriendlyErrorMessage(error)} +

+
+
+ +
+
+
+ ) : ( +
+
+ {summaryCards.map(card => ( + +

{card.title}

+

+ {card.value} +

+

+ {card.description} +

+
+ ))} +
+ + {configs.length === 0 ? ( + } + action={{ + label: 'Create configuration', + onClick: openCreateDialog, + variant: 'filled', + }} + /> + ) : ( + + )} +
+ )} + + +
+ + setFormState(prev => ({ + ...prev, + adminCCEmails: String(event.target.value || ''), + })) + } + placeholder="admin1@airqo.net, admin2@airqo.net" + description="Comma-separated admin emails to copy on automated alerts." + required={dialogState?.mode === 'create'} + /> + + {dialogState?.mode === 'edit' && ( + + )} + +
+ + setFormState(prev => ({ + ...prev, + applicationEmails: String(event.target.value || ''), + })) + } + placeholder={'app-client@airqo.net\nmonitoring-bot@airqo.net'} + rows={6} + /> +

+ {dialogState?.mode === 'edit' + ? formState.mode === 'add' + ? 'Add one email per line. These emails will be appended to the existing list.' + : formState.mode === 'remove' + ? 'Add one email per line. These emails will be removed from the existing list.' + : 'Add one email per line. Leaving this blank will clear the current application email list.' + : 'Optional. Add one email per line.'} +

+
+ +
+ + +
+
+
+ + +
+

+ This will permanently remove the configuration for{' '} + + {deleteConfig ? shortenId(deleteConfig._id) : ''} + + . Any automated alerts using this config will stop copying the + configured admin CC recipients. +

+ + {deleteConfig && ( +
+
+

+ Admin CC emails +

+

+ {parseEmailList(deleteConfig.adminCCEmails || '').length} +

+
+
+

+ Application emails +

+

+ {(deleteConfig.applicationEmails || []).length} +

+
+
+ )} + +
+ + +
+
+
+
+
+ ); +}; + +export default EmailConfigContent; diff --git a/src/platform/src/app/(dashboard)/system/org-requests/page.tsx b/src/platform/src/app/(dashboard)/system/org-requests/page.tsx index a8e84c9248..dd98077b1b 100644 --- a/src/platform/src/app/(dashboard)/system/org-requests/page.tsx +++ b/src/platform/src/app/(dashboard)/system/org-requests/page.tsx @@ -10,6 +10,7 @@ import PageHeading from '@/shared/components/ui/page-heading'; import { TextInput } from '@/shared/components/ui/text-input'; import { formatWithPattern } from '@/shared/utils/dateUtils'; import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import { refreshWithToast } from '@/shared/utils/refreshWithToast'; import { useOrganizationRequests, useApproveOrganizationRequest, @@ -104,23 +105,21 @@ const OrganizationRequestsPage = () => { // Apply search filter if (searchTerm.trim()) { const searchLower = searchTerm.toLowerCase(); - filtered = filtered.filter( - request => { - const projectName = getProjectName(request).toLowerCase(); - const city = (request.city || '').toLowerCase(); - const legacySlug = (request.organization_slug || '').toLowerCase(); - const partner = getPartnerName(request).toLowerCase(); - - return ( - projectName.includes(searchLower) || - request.contact_name.toLowerCase().includes(searchLower) || - request.contact_email.toLowerCase().includes(searchLower) || - city.includes(searchLower) || - partner.includes(searchLower) || - legacySlug.includes(searchLower) - ); - } - ); + filtered = filtered.filter(request => { + const projectName = getProjectName(request).toLowerCase(); + const city = (request.city || '').toLowerCase(); + const legacySlug = (request.organization_slug || '').toLowerCase(); + const partner = getPartnerName(request).toLowerCase(); + + return ( + projectName.includes(searchLower) || + request.contact_name.toLowerCase().includes(searchLower) || + request.contact_email.toLowerCase().includes(searchLower) || + city.includes(searchLower) || + partner.includes(searchLower) || + legacySlug.includes(searchLower) + ); + }); } return filtered; @@ -151,9 +150,17 @@ const OrganizationRequestsPage = () => { : null; }, [confirmDialog, requests]); - const handleRefresh = () => { - mutate(); - }; + const handleRefresh = useCallback(async () => { + try { + await refreshWithToast( + () => mutate(), + 'Organization requests refreshed successfully' + ); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + console.error('Refresh organization requests error:', error); + } + }, [mutate]); const handleApprove = useCallback((requestId: string) => { setConfirmDialog({ type: 'approve', id: requestId }); @@ -367,23 +374,24 @@ const OrganizationRequestsPage = () => { variant={activeTab === 'pending' ? 'filled' : 'outlined'} onClick={() => setActiveTab('pending')} > - Pending ({requests.filter(r => r.status === 'pending').length}) + Pending ({requests.filter(r => r.status === 'pending').length} + ) + )} + + ))} + + + + + ); +}; + +interface BlockedAsnDialogProps { + isOpen: boolean; + onClose: () => void; + entry: BlockedAsn | null; + onSuccess: () => void; +} + +const BlockedAsnDialog: React.FC = ({ + isOpen, + onClose, + entry, + onSuccess, +}) => { + const [provider, setProvider] = useState(''); + const [asn, setAsn] = useState(''); + const [cidrRanges, setCidrRanges] = useState(['']); + const [cidrErrors, setCidrErrors] = useState(['']); + const [reason, setReason] = useState(''); + const [active, setActive] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (!isOpen) { + return; + } + + setProvider(entry?.provider || ''); + setAsn(entry?.asn || ''); + const cidrs = entry?.cidr_ranges || []; + setCidrRanges(cidrs.length > 0 ? cidrs : ['']); + setCidrErrors(cidrs.length > 0 ? cidrs.map(() => '') : ['']); + setReason(entry?.reason || ''); + setActive(entry?.active ?? true); + }, [entry, isOpen]); + + const handleAddCidr = () => { + setCidrRanges(previous => [...previous, '']); + setCidrErrors(previous => [...previous, '']); + }; + + const handleRemoveCidr = (index: number) => { + if (cidrRanges.length <= 1) { + return; + } + + setCidrRanges(previous => previous.filter((_, i) => i !== index)); + setCidrErrors(previous => previous.filter((_, i) => i !== index)); + }; + + const handleCidrChange = (index: number, value: string) => { + setCidrRanges(previous => { + const next = [...previous]; + next[index] = value; + return next; + }); + + setCidrErrors(previous => { + const next = [...previous]; + next[index] = ''; + return next; + }); + }; + + const handleSubmit = async () => { + const normalizedProvider = provider.trim(); + const normalizedAsn = asn.trim().toUpperCase(); + const normalizedCidrs = cidrRanges + .map(value => value.trim()) + .filter(Boolean); + const normalizedReason = reason.trim(); + + if (!normalizedProvider) { + toast.error('Provider is required'); + return; + } + + if (!normalizedAsn && normalizedCidrs.length === 0) { + toast.error('Provide either an ASN or at least one CIDR range'); + return; + } + + if (normalizedAsn && !isValidAsn(normalizedAsn)) { + toast.error('Enter a valid ASN in the format AS12345'); + return; + } + + const nextCidrErrors = cidrRanges.map(value => { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + return isValidCidrNotation(trimmed) + ? '' + : 'Enter a valid IPv4 CIDR range, for example 192.0.2.0/24'; + }); + setCidrErrors(nextCidrErrors); + + if (nextCidrErrors.some(Boolean)) { + return; + } + + setIsSubmitting(true); + try { + const payload: CreateBlockedAsnRequest = { + provider: normalizedProvider, + asn: normalizedAsn || undefined, + cidr_ranges: normalizedCidrs.length > 0 ? normalizedCidrs : undefined, + reason: normalizedReason || undefined, + active, + }; + + await adminService.createBlockedASN(payload); + toast.success( + entry + ? 'Blocked range updated successfully' + : 'Blocked range created successfully' + ); + onSuccess(); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + console.error('Blocked ASN save error:', sanitizeErrorForLogging(error)); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (!isSubmitting) { + onClose(); + } + }; + + return ( + +
+ setProvider(e.target.value)} + placeholder="Amazon Web Services" + description="Human-readable provider name, e.g. Cloudflare or DigitalOcean." + required + /> + +
+ setAsn(e.target.value)} + placeholder="AS16509" + description="Optional if CIDR ranges are provided." + /> + +
+
+ +
+

Active

+

+ Inactive entries remain in the list but are not enforced. +

+
+
+
+
+ + + + setReason(e.target.value)} + placeholder="AWS data-center range" + rows={4} + /> +
+
+ ); +}; + +interface ResolveFlaggedTokenDialogProps { + isOpen: boolean; + onClose: () => void; + item: FlaggedToken | null; + onSuccess: () => void; +} + +const ResolveFlaggedTokenDialog: React.FC = ({ + isOpen, + onClose, + item, + onSuccess, +}) => { + const [note, setNote] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (isOpen) { + setNote(item?.resolution_note || ''); + } + }, [isOpen, item]); + + const handleClose = () => { + if (!isSubmitting) { + onClose(); + } + }; + + const handleResolve = async () => { + if (!item) { + toast.error('Flagged token data is unavailable'); + return; + } + + setIsSubmitting(true); + try { + await adminService.resolveFlaggedToken(item._id, { + note: note.trim() || undefined, + }); + toast.success('Flagged token resolved successfully'); + onSuccess(); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + console.error( + 'Resolve flagged token error:', + sanitizeErrorForLogging(error) + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ +
+
+

IP

+

{item?.ip || '—'}

+
+
+

Service

+

{item?.service || '—'}

+
+
+

Action taken

+

{item?.action_taken || '—'}

+
+
+

Flagged at

+

{item?.flagged_at ? formatDate(item.flagged_at) : '—'}

+
+
+
+ + setNote(e.target.value)} + placeholder="Investigated automated scanner. Token owner notified and key rotated." + rows={5} + /> +
+
+ ); +}; + +const SecurityPageContent: React.FC = () => { + const [activeTab, setActiveTab] = useState('blocked-asns'); + const [blockedFilter, setBlockedFilter] = useState< + 'all' | 'active' | 'inactive' + >('all'); + const [flaggedFilter, setFlaggedFilter] = useState< + 'all' | 'open' | 'resolved' + >('open'); + const [blockedDialogEntry, setBlockedDialogEntry] = + useState(null); + const [blockedDialogOpen, setBlockedDialogOpen] = useState(false); + const [resolveDialogEntry, setResolveDialogEntry] = + useState(null); + const [resolveDialogOpen, setResolveDialogOpen] = useState(false); + const [deleteDialogEntry, setDeleteDialogEntry] = useState( + null + ); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { + data: blockedAsns, + error: blockedError, + isLoading: blockedLoading, + mutate: mutateBlocked, + } = useSWR('system/security/blocked-asns', fetchAllBlockedAsns, { + revalidateOnFocus: false, + shouldRetryOnError: false, + }); + + const { + data: flaggedTokens, + error: flaggedError, + isLoading: flaggedLoading, + mutate: mutateFlagged, + } = useSWR('system/security/flagged-tokens', fetchAllFlaggedTokens, { + revalidateOnFocus: false, + shouldRetryOnError: false, + }); + + const blockedSummary = useMemo(() => { + const items = blockedAsns || []; + return { + total: items.length, + active: items.filter(item => item.active).length, + inactive: items.filter(item => !item.active).length, + }; + }, [blockedAsns]); + + const flaggedSummary = useMemo(() => { + const items = flaggedTokens || []; + return { + total: items.length, + open: items.filter(item => !item.resolved).length, + resolved: items.filter(item => item.resolved).length, + }; + }, [flaggedTokens]); + + const filteredBlockedRows = useMemo(() => { + const items = blockedAsns || []; + const filtered = items.filter(item => { + if (blockedFilter === 'active') return item.active; + if (blockedFilter === 'inactive') return !item.active; + return true; + }); + + return filtered.map(item => ({ ...item, id: item._id })); + }, [blockedAsns, blockedFilter]); + + const filteredFlaggedRows = useMemo(() => { + const items = flaggedTokens || []; + const filtered = items.filter(item => { + if (flaggedFilter === 'open') return !item.resolved; + if (flaggedFilter === 'resolved') return item.resolved; + return true; + }); + + return filtered.map(item => ({ ...item, id: item._id })); + }, [flaggedTokens, flaggedFilter]); + + const handleEditBlockedAsn = useCallback((entry: BlockedAsn) => { + setBlockedDialogEntry(entry); + setBlockedDialogOpen(true); + }, []); + + const handleDeleteBlockedAsn = useCallback((entry: BlockedAsn) => { + setDeleteDialogEntry(entry); + setDeleteDialogOpen(true); + }, []); + + const handleResolveToken = useCallback((entry: FlaggedToken) => { + setResolveDialogEntry(entry); + setResolveDialogOpen(true); + }, []); + + const handleCreateBlockedAsn = useCallback(() => { + setBlockedDialogEntry(null); + setBlockedDialogOpen(true); + }, []); + + const handleBlockedDialogSuccess = useCallback(async () => { + setBlockedDialogOpen(false); + setBlockedDialogEntry(null); + await mutateBlocked(); + }, [mutateBlocked]); + + const handleResolveDialogSuccess = useCallback(async () => { + setResolveDialogOpen(false); + setResolveDialogEntry(null); + await mutateFlagged(); + }, [mutateFlagged]); + + const handleRefreshBlocked = useCallback(async () => { + try { + await refreshWithToast( + () => mutateBlocked(), + 'Blocked ASN rules refreshed successfully' + ); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } + }, [mutateBlocked]); + + const handleRefreshFlagged = useCallback(async () => { + try { + await refreshWithToast( + () => mutateFlagged(), + 'Flagged tokens refreshed successfully' + ); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } + }, [mutateFlagged]); + + const handleDeleteBlockedAsnConfirm = async () => { + if (!deleteDialogEntry) { + return; + } + + try { + await adminService.deleteBlockedASN(deleteDialogEntry._id); + toast.success('Blocked ASN deleted successfully'); + setDeleteDialogOpen(false); + setDeleteDialogEntry(null); + await mutateBlocked(); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + console.error( + 'Delete blocked ASN error:', + sanitizeErrorForLogging(error) + ); + } + }; + + const blockedColumns = useMemo( + () => [ + { + key: 'provider', + label: 'Provider', + minWidth: '220px', + render: (_value: unknown, item: BlockedAsnRow) => ( +
+

{item.provider}

+

+ {item.asn || 'ASN not provided'} +

+
+ ), + }, + { + key: 'cidr_ranges', + label: 'CIDR ranges', + minWidth: '260px', + render: (_value: unknown, item: BlockedAsnRow) => { + const cidrRanges = item.cidr_ranges ?? []; + const visible = cidrRanges.slice(0, 2); + const more = Math.max(0, cidrRanges.length - visible.length); + return ( +
+ {visible.map(range => ( + + {range} + + ))} + {more > 0 && ( + + +{more} more + + )} +
+ ); + }, + }, + { + key: 'reason', + label: 'Reason', + minWidth: '240px', + render: (value: unknown) => ( + + {String(value || '—')} + + ), + }, + { + key: 'active', + label: 'Status', + minWidth: '110px', + render: (value: unknown, item: BlockedAsnRow) => ( + + {item.active ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'blockedAt', + label: 'Blocked', + minWidth: '170px', + render: (value: unknown) => ( + + {value ? formatDate(String(value)) : '—'} + + ), + }, + { + key: 'actions', + label: 'Actions', + minWidth: '120px', + render: (_value: unknown, item: BlockedAsnRow) => ( +
+ + +
+ ), + }, + ], + [handleDeleteBlockedAsn, handleEditBlockedAsn] + ); + + const flaggedColumns = useMemo( + () => [ + { + key: 'token_suffix', + label: 'Token', + minWidth: '120px', + render: (value: unknown) => ( + + ****{String(value || '')} + + ), + }, + { + key: 'ip', + label: 'IP', + minWidth: '140px', + render: (value: unknown) => ( + {String(value || '—')} + ), + }, + { + key: 'user_agent', + label: 'User agent', + minWidth: '220px', + maxWidth: '320px', + render: (value: unknown) => ( + + {String(value || '—')} + + ), + }, + { + key: 'honeypot_path', + label: 'Path', + minWidth: '220px', + render: (value: unknown) => ( + + {String(value || '—')} + + ), + }, + { + key: 'service', + label: 'Service', + minWidth: '140px', + render: (value: unknown) => ( + + {String(value || '—')} + + ), + }, + { + key: 'action_taken', + label: 'Action', + minWidth: '120px', + render: (value: unknown) => ( + + {String(value || '—')} + + ), + }, + { + key: 'flagged_at', + label: 'Flagged', + minWidth: '170px', + render: (value: unknown) => ( + + {value ? formatDate(String(value)) : '—'} + + ), + }, + { + key: 'resolved', + label: 'Status', + minWidth: '110px', + render: (_value: unknown, item: FlaggedTokenRow) => ( + + {item.resolved ? 'Resolved' : 'Open'} + + ), + }, + { + key: 'actions', + label: 'Actions', + minWidth: '120px', + render: (_value: unknown, item: FlaggedTokenRow) => ( +
+ {!item.resolved ? ( + + ) : ( + Reviewed + )} +
+ ), + }, + ], + [handleResolveToken] + ); + + const summaryCards = [ + { + title: 'Blocked entries', + value: blockedSummary.total, + description: `${blockedSummary.active} active · ${blockedSummary.inactive} inactive`, + }, + { + title: 'Flagged tokens', + value: flaggedSummary.total, + description: `${flaggedSummary.open} open · ${flaggedSummary.resolved} resolved`, + }, + ]; + + return ( +
+ + Add Blocked ASN + + ) : undefined + } + /> + +
+ {summaryCards.map(card => ( + +

{card.title}

+

+ {card.value} +

+

+ {card.description} +

+
+ ))} +
+ + +
+ + +
+
+ + {activeTab === 'blocked-asns' ? ( +
+
+ + + + +
+ + +
+ ) : ( +
+
+ + + + +
+ + +
+ )} + + setBlockedDialogOpen(false)} + entry={blockedDialogEntry} + onSuccess={handleBlockedDialogSuccess} + /> + + setResolveDialogOpen(false)} + item={resolveDialogEntry} + onSuccess={handleResolveDialogSuccess} + /> + + setDeleteDialogOpen(false)} + title="Delete blocked ASN" + size="md" + > +
+

+ Are you sure you want to delete the block for{' '} + {deleteDialogEntry?.provider} + ? This will immediately remove the rule after the backend cache + expires. +

+
+ + +
+
+
+
+ ); +}; + +const SystemSecurityPage: React.FC = () => { + return ( + + + + ); +}; + +export default SystemSecurityPage; diff --git a/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/edit/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/edit/page.tsx new file mode 100644 index 0000000000..e6be36b103 --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/edit/page.tsx @@ -0,0 +1,113 @@ +'use client'; + +import React, { useCallback, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import useSWR, { useSWRConfig } from 'swr'; +import { PermissionGuard } from '@/shared/components'; +import { Card, LoadingState } from '@/shared/components/ui'; +import type { + CreateSurveyRequest, + Survey, + UpdateSurveyRequest, +} from '@/shared/types/api'; +import { surveyService } from '@/shared/services'; +import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import SurveyForm from '../../components/SurveyForm'; + +const SurveyEditPage: React.FC = () => { + const params = useParams<{ surveyId: string }>(); + const router = useRouter(); + const { mutate } = useSWRConfig(); + const surveyId = params?.surveyId; + + const { + data: survey, + error, + isLoading, + mutate: refreshSurvey, + } = useSWR( + surveyId ? `system/surveys/${surveyId}` : null, + () => surveyService.getSurveyById(surveyId as string), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + useEffect(() => { + if (!surveyId) { + router.push('/system/surveys'); + } + }, [router, surveyId]); + + const handleCancel = useCallback(() => { + router.push(`/system/surveys/${surveyId}`); + }, [router, surveyId]); + + const handleSubmit = useCallback( + async (payload: CreateSurveyRequest | UpdateSurveyRequest) => { + return surveyService.updateSurvey( + surveyId as string, + payload as UpdateSurveyRequest + ); + }, + [surveyId] + ); + + const handleSuccess = useCallback( + async (updatedSurvey: Survey) => { + await Promise.allSettled([ + mutate('system/surveys'), + mutate(`system/surveys/${updatedSurvey._id}`), + refreshSurvey(), + ]); + + router.replace(`/system/surveys/${updatedSurvey._id}`); + }, + [mutate, refreshSurvey, router] + ); + + const surveyErrorMessage = error + ? getUserFriendlyErrorMessage(error) + : 'Survey not found.'; + + return ( + + {isLoading ? ( + + ) : error || !survey ? ( + +
+

+ {surveyErrorMessage} +

+ +
+
+ ) : ( + + )} +
+ ); +}; + +export default SurveyEditPage; diff --git a/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx new file mode 100644 index 0000000000..a7d4270c48 --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/surveys/[surveyId]/page.tsx @@ -0,0 +1,1132 @@ +'use client'; + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import useSWR, { useSWRConfig } from 'swr'; +import jsPDF from 'jspdf'; +import autoTable from 'jspdf-autotable'; +import { PermissionGuard } from '@/shared/components'; +import { + Button, + Card, + Dialog, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + LoadingState, + PageHeading, + toast, +} from '@/shared/components/ui'; +import { ServerSideTable } from '@/shared/components/ui/server-side-table'; +import { + AqArrowLeft, + AqDownload01, + AqEdit05, + AqEye, + AqRefreshCw05, + AqTrash01, +} from '@airqo/icons-react'; +import { surveyService } from '@/shared/services'; +import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import { refreshWithToast } from '@/shared/utils/refreshWithToast'; +import type { Survey, SurveyResponseItem } from '@/shared/types/api'; +import SurveyResponseDialog from '../components/SurveyResponseDialog'; +import { + formatDateTime, + formatDuration, + formatQuestionTypeLabel, + formatSurveyStatus, + getQuestionDistribution, + getSurveyTriggerLabel, + getTopAnswerEntries, + getTotalAnswerCount, + formatResponseValue, +} from '../utils'; + +type SurveyResponseRow = SurveyResponseItem & { + id: string; + [key: string]: unknown; +}; + +const RESPONSE_STATUS_TONES: Record = { + completed: + 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300', + skipped: + 'bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300', + partial: 'bg-blue-100 text-blue-800 dark:bg-blue-950/40 dark:text-blue-300', +}; + +const getResponseStatusTone = (value?: string) => { + const normalized = String(value || '').toLowerCase(); + return ( + RESPONSE_STATUS_TONES[normalized] || + 'bg-slate-100 text-slate-700 dark:bg-slate-950/40 dark:text-slate-300' + ); +}; + +const getRespondentLabel = (response: SurveyResponseItem) => { + if (response.user?.email) { + return response.user.email; + } + + if (response.userId) { + return response.userId; + } + + return response.isGuest ? 'Guest respondent' : 'Unknown respondent'; +}; + +type SurveyResponseExportRow = { + respondent: string; + status: string; + answersSummary: string; + timeSpent: string; + submittedAt: string; + responseId: string; + surveyId: string; + userId: string; + deviceId: string; + [questionKey: string]: string; +}; + +const escapeCsvValue = (value: string): string => { + const normalized = String(value ?? ''); + const firstNonWhitespace = normalized.trimStart().charAt(0); + const startsWithFormulaChar = ['=', '+', '-', '@'].includes( + firstNonWhitespace + ); + const startsWithTabOrCarriageReturn = + normalized.charAt(0) === '\t' || normalized.charAt(0) === '\r'; + + const prefixed = + startsWithFormulaChar || startsWithTabOrCarriageReturn + ? `'${normalized}` + : normalized; + + return prefixed.replace(/"/g, '""'); +}; + +const buildSurveyResponseExportRows = ( + survey: Survey | null | undefined, + responses: SurveyResponseItem[] +): SurveyResponseExportRow[] => { + return responses.map(response => { + const base: SurveyResponseExportRow = { + respondent: getRespondentLabel(response), + status: formatQuestionTypeLabel(response.status || 'unknown'), + answersSummary: `${response.answerCount ?? response.answers.length} of ${survey?.questions?.length ?? '?'} answered`, + timeSpent: formatDuration(response.timeToComplete), + submittedAt: formatDateTime(response.completedAt || response.createdAt), + responseId: response._id, + surveyId: response.surveyId, + userId: response.userId || 'Not tracked', + deviceId: response.deviceId || 'Not tracked', + }; + + response.answers.forEach(answer => { + const question = survey?.questions.find( + item => item.id === answer.questionId + ); + const label = question?.question || answer.questionId; + base[`Q: ${label}`] = formatResponseValue(answer.answer); + }); + + return base; + }); +}; + +const exportSurveyResponsesAsCsv = ( + survey: Survey | null | undefined, + responses: SurveyResponseItem[] +) => { + const rows = buildSurveyResponseExportRows(survey, responses); + const questionColumns = survey?.questions?.map(q => `Q: ${q.question}`) || []; + const headers = [ + 'Respondent', + 'Status', + 'Answers summary', + 'Time spent', + 'Submitted at', + 'Response ID', + 'Survey ID', + 'User ID', + 'Device ID', + ...questionColumns, + ]; + + const csvBody = rows.map(row => { + const base = [ + row.respondent, + row.status, + row.answersSummary, + row.timeSpent, + row.submittedAt, + row.responseId, + row.surveyId, + row.userId, + row.deviceId, + ]; + questionColumns.forEach(col => { + base.push(String(row[col] || '')); + }); + return base; + }); + + const csv = [headers, ...csvBody] + .map(row => row.map(value => `"${escapeCsvValue(value)}"`).join(',')) + .join('\n'); + + const fileName = `survey-responses-${ + survey?.title + ?.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'export' + }.csv`; + + const blob = new Blob(['\uFEFF' + csv], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +}; + +const exportSurveyResponsesAsPdf = ( + survey: Survey | null | undefined, + responses: SurveyResponseItem[] +) => { + const rows = buildSurveyResponseExportRows(survey, responses); + const doc = new jsPDF({ unit: 'pt', format: 'a4' }); + const generatedAt = new Date().toLocaleString(); + + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text('AirQo Survey Responses', 40, 50); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text(`Survey: ${survey?.title || 'Survey responses'}`, 40, 70); + doc.text(`Survey ID: ${survey?._id || 'Not available'}`, 40, 84); + doc.text(`Total responses: ${responses.length}`, 40, 98); + doc.text(`Generated on: ${generatedAt}`, 40, 112); + + autoTable(doc, { + head: [ + [ + 'Respondent', + 'Status', + 'Answers', + 'Time spent', + 'Submitted at', + 'Response ID', + 'Device ID', + ], + ], + body: rows.map(row => [ + row.respondent, + row.status, + row.answersSummary, + row.timeSpent, + row.submittedAt, + row.responseId, + row.deviceId, + ]), + startY: 130, + styles: { + fontSize: 8.5, + cellPadding: 4, + overflow: 'linebreak', + valign: 'top', + }, + headStyles: { + fillColor: [22, 78, 99], + textColor: [255, 255, 255], + fontStyle: 'bold', + }, + alternateRowStyles: { + fillColor: [245, 247, 250], + }, + margin: { left: 40, right: 40 }, + }); + + const pageCount = doc.getNumberOfPages(); + for (let index = 1; index <= pageCount; index += 1) { + doc.setPage(index); + doc.setFontSize(9); + doc.text( + `Page ${index} of ${pageCount}`, + 40, + doc.internal.pageSize.height - 30 + ); + } + + const fileName = `survey-responses-${ + survey?.title + ?.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'export' + }.pdf`; + + doc.save(fileName); +}; + +const SurveyDetailsPage: React.FC = () => { + const params = useParams<{ surveyId: string }>(); + const router = useRouter(); + const { mutate: globalMutate } = useSWRConfig(); + const surveyId = params?.surveyId; + + const [isDeleting, setIsDeleting] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedResponse, setSelectedResponse] = + useState(null); + + const { + data: survey, + error: surveyError, + isLoading: surveyLoading, + mutate: mutateSurvey, + } = useSWR( + surveyId ? `system/surveys/${surveyId}` : null, + () => surveyService.getSurveyById(surveyId as string), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + const { + data: stats, + error: statsError, + isLoading: statsLoading, + mutate: mutateStats, + } = useSWR( + surveyId ? `system/surveys/stats/${surveyId}` : null, + () => surveyService.getSurveyStats(surveyId as string), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + const { + data: responses = [], + error: responsesError, + isLoading: responsesLoading, + mutate: mutateResponses, + } = useSWR( + 'system/surveys/responses', + () => surveyService.getSurveyResponses(), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + useEffect(() => { + if (!surveyId) { + router.push('/system/surveys'); + } + }, [router, surveyId]); + + const currentSurvey = survey as Survey | undefined; + + const filteredResponses = useMemo(() => { + return responses + .filter(response => response.surveyId === surveyId) + .slice() + .sort((left, right) => { + const leftTime = new Date(left.completedAt || left.createdAt).getTime(); + const rightTime = new Date( + right.completedAt || right.createdAt + ).getTime(); + return rightTime - leftTime; + }); + }, [responses, surveyId]); + + const responseRows = useMemo( + () => + filteredResponses.map(response => ({ + ...response, + id: response._id, + })), + [filteredResponses] + ); + + const overviewSummary = useMemo(() => { + if (!currentSurvey) { + return []; + } + + return [ + { + label: 'Question count', + value: currentSurvey.questionCount ?? currentSurvey.questions.length, + }, + { + label: 'Required questions', + value: + currentSurvey.requiredQuestionCount ?? + currentSurvey.questions.filter(question => question.isRequired) + .length, + }, + { + label: 'Estimated time', + value: formatDuration(currentSurvey.timeToComplete), + }, + { + label: 'Survey status', + value: formatSurveyStatus( + currentSurvey.isActive, + currentSurvey.surveyStatus, + currentSurvey.isExpired + ).label, + }, + ]; + }, [currentSurvey]); + + const refreshSurveyData = useCallback(async () => { + const results = await Promise.allSettled([ + mutateSurvey(), + mutateStats(), + mutateResponses(), + globalMutate('system/surveys'), + ]); + + if (!results.some(result => result.status === 'fulfilled')) { + throw new Error('Unable to refresh survey data'); + } + }, [globalMutate, mutateResponses, mutateStats, mutateSurvey]); + + const handleBack = useCallback(() => { + router.push('/system/surveys'); + }, [router]); + + const handleRefresh = useCallback(async () => { + try { + await refreshWithToast( + refreshSurveyData, + 'Survey data refreshed successfully' + ); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } + }, [refreshSurveyData]); + + const handleEditClick = useCallback(() => { + if (surveyId) { + router.push(`/system/surveys/${surveyId}/edit`); + } + }, [router, surveyId]); + + const handleDeleteSurvey = useCallback(async () => { + if (!currentSurvey) { + return; + } + + setIsDeleting(true); + try { + await surveyService.deleteSurvey(currentSurvey._id); + toast.success('Survey deleted successfully'); + setDeleteDialogOpen(false); + await Promise.allSettled([ + globalMutate('system/surveys'), + mutateSurvey(), + mutateStats(), + mutateResponses(), + ]); + router.push('/system/surveys'); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } finally { + setIsDeleting(false); + } + }, [ + currentSurvey, + globalMutate, + mutateResponses, + mutateStats, + mutateSurvey, + router, + ]); + + const handleExportCsv = useCallback(() => { + exportSurveyResponsesAsCsv(currentSurvey, filteredResponses); + }, [currentSurvey, filteredResponses]); + + const handleExportPdf = useCallback(() => { + exportSurveyResponsesAsPdf(currentSurvey, filteredResponses); + }, [currentSurvey, filteredResponses]); + + const responseColumns = useMemo( + () => [ + { + key: 'respondent', + label: 'Respondent', + minWidth: '200px', + render: (_value: unknown, item: SurveyResponseRow) => ( +
+

+ {getRespondentLabel(item)} +

+

+ {item.isGuest + ? 'Guest response' + : item.user + ? 'Registered user' + : 'No profile'} +

+
+ ), + }, + { + key: 'status', + label: 'Status', + minWidth: '110px', + render: (value: unknown) => ( + + {formatQuestionTypeLabel(String(value || 'unknown'))} + + ), + }, + { + key: 'answers', + label: 'Answers', + minWidth: '140px', + render: (_value: unknown, item: SurveyResponseRow) => { + const answerCount = item.answerCount ?? item.answers.length; + const totalQuestions = currentSurvey?.questions?.length ?? '?'; + + return ( +

+ {answerCount} of {totalQuestions} answered +

+ ); + }, + }, + { + key: 'deviceId', + label: 'Device', + minWidth: '140px', + render: (value: unknown) => ( + + {String(value || 'Not tracked')} + + ), + }, + { + key: 'timeToComplete', + label: 'Time spent', + minWidth: '100px', + render: (value: unknown) => ( + + {formatDuration(value as number | null | undefined)} + + ), + }, + { + key: 'submitted', + label: 'Submitted', + minWidth: '160px', + render: (_value: unknown, item: SurveyResponseRow) => ( + + {formatDateTime(item.completedAt || item.createdAt)} + + ), + }, + { + key: 'actions', + label: '', + minWidth: '80px', + render: (_value: unknown, item: SurveyResponseRow) => ( + + ), + }, + ], + [currentSurvey] + ); + + if (surveyLoading) { + return ( + + + + ); + } + + if (surveyError || !currentSurvey) { + return ( + +
+ + +

+ {surveyError + ? getUserFriendlyErrorMessage(surveyError) + : 'Survey not found.'} +

+
+
+
+ ); + } + + const status = formatSurveyStatus( + currentSurvey.isActive, + currentSurvey.surveyStatus, + currentSurvey.isExpired + ); + const triggerLabel = getSurveyTriggerLabel(currentSurvey.trigger); + + return ( + +
+
+ +
+ + + + + +
+ } + > +
+ + {status.label} + + + {currentSurvey.questionCount ?? currentSurvey.questions.length}{' '} + questions + + + {formatDuration(currentSurvey.timeToComplete)} + + + Expires {formatDateTime(currentSurvey.expiresAt)} + +
+ + +
+
+ +
+
+

+ Survey overview +

+

+ Basic configuration and lifecycle details. +

+
+ + {triggerLabel} + +
+ +
+ {overviewSummary.map(item => ( +
+

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+

+ Dates +

+
+
+

Created

+

+ {formatDateTime(currentSurvey.createdAt)} +

+
+
+

Updated

+

+ {formatDateTime(currentSurvey.updatedAt)} +

+
+
+
+
+
+ + +
+
+

+ Questions +

+

+ The survey structure and the strongest answer patterns. +

+
+ + {currentSurvey.questions.length} total + +
+ +
+ {currentSurvey.questions.length > 0 ? ( + currentSurvey.questions.map((question, index) => { + const distribution = getQuestionDistribution( + stats?.answerDistribution, + question.id + ); + const topAnswers = getTopAnswerEntries(distribution); + const maxCount = topAnswers[0]?.[1] || 0; + const totalAnswers = getTotalAnswerCount(distribution); + + return ( +
+
+
+ + Question {index + 1} + + + {formatQuestionTypeLabel(question.type)} + + + {question.isRequired ? 'Required' : 'Optional'} + +
+

+ {question.question} +

+

+ Question ID: {question.id} +

+ + {question.type === 'multipleChoice' && + question.options.length > 0 ? ( +
+

+ Options +

+
+ {question.options.map(option => ( + + {option} + + ))} +
+
+ ) : question.type === 'rating' || + question.type === 'scale' ? ( +
+

+ Range +

+

+ {question.minValue ?? '1'} to{' '} + {question.maxValue ?? '5'} +

+
+ ) : question.type === 'yesNo' ? ( +
+

+ Response format +

+

+ Fixed Yes / No choice +

+
+ ) : ( +
+

+ Response format +

+

+ Free text response +

+ {question.placeholder && ( +

+ Placeholder: {question.placeholder} +

+ )} +
+ )} + + {topAnswers.length > 0 && ( +
+
+

+ Top responses +

+

+ {totalAnswers} recorded +

+
+
+ {topAnswers.map(([answer, count]) => ( +
+
+ + {answer} + + + {count} + +
+
+
+
+
+ ))} +
+
+ )} +
+
+ ); + }) + ) : ( +
+ This survey does not have any questions yet. +
+ )} +
+ +
+ +
+ +
+
+

+ Survey stats +

+

+ Response health and completion performance. +

+
+ + Analytics + +
+ + {statsLoading ? ( +
+ Loading survey statistics... +
+ ) : statsError ? ( +
+ {getUserFriendlyErrorMessage(statsError)} +
+ ) : stats ? ( +
+
+
+

+ Total responses +

+

+ {stats.totalResponses} +

+
+
+

+ Completion rate +

+

+ {stats.completionRate}% +

+
+
+
+
+
+

+ Completed +

+

+ {stats.completedResponses} +

+
+
+

+ Skipped +

+

+ {stats.skippedResponses} +

+
+
+

+ Average completion time +

+

+ {formatDuration(stats.averageCompletionTime)} +

+
+
+
+ ) : null} + + + +
+
+

+ Survey metadata +

+

+ Lifecycle details and trigger context. +

+
+
+ +
+
+

+ Trigger +

+

+ {triggerLabel} +

+
+
+

+ Status +

+

+ {status.label} +

+
+
+

+ Time to complete +

+

+ {formatDuration(currentSurvey.timeToComplete)} +

+
+
+

+ Expires +

+

+ {formatDateTime(currentSurvey.expiresAt)} +

+
+
+

+ Created +

+

+ {formatDateTime(currentSurvey.createdAt)} +

+
+
+

+ Updated +

+

+ {formatDateTime(currentSurvey.updatedAt)} +

+
+
+
+
+
+ + +
+
+

+ Responses +

+

+ Review individual submissions and export the collected data. +

+
+
+ + {filteredResponses.length} matched + + + + + + + + Export CSV + + + Export PDF + + + +
+
+ +
+ +
+
+ + setDeleteDialogOpen(false)} + title="Delete survey" + subtitle="This action permanently removes the survey and its management entry." + size="md" + primaryAction={{ + label: 'Delete survey', + onClick: handleDeleteSurvey, + variant: 'danger', + disabled: isDeleting, + loading: isDeleting, + }} + secondaryAction={{ + label: 'Cancel', + onClick: () => setDeleteDialogOpen(false), + variant: 'outlined', + disabled: isDeleting, + }} + > +

+ Are you sure you want to delete{' '} + + {currentSurvey.title} + + ? This cannot be undone. +

+
+ + setSelectedResponse(null)} + response={selectedResponse} + survey={currentSurvey} + /> +
+ + ); +}; + +export default SurveyDetailsPage; diff --git a/src/platform/src/app/(dashboard)/system/surveys/components/SurveyEditorDialog.tsx b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyEditorDialog.tsx new file mode 100644 index 0000000000..47c92790f0 --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyEditorDialog.tsx @@ -0,0 +1,443 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { AqPlus, AqTrash01 } from '@airqo/icons-react'; +import { + Button, + Checkbox, + Dialog, + Input, + TextInput, + Select, + toast, +} from '@/shared/components/ui'; +import { surveyService } from '@/shared/services'; +import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import type { Survey } from '@/shared/types/api'; +import { + SURVEY_QUESTION_TYPES, + createQuestionDraft, + formatQuestionTypeLabel, + serializeQuestionDraft, + type SurveyQuestionDraft, +} from '../utils'; + +interface SurveyEditorDialogProps { + isOpen: boolean; + survey: Survey | null; + onClose: () => void; + onSaved: (updatedSurvey: Survey) => Promise | void; +} + +const buildInitialQuestions = ( + survey: Survey | null +): SurveyQuestionDraft[] => { + if (!survey?.questions?.length) { + return [createQuestionDraft()]; + } + + return survey.questions.map(question => createQuestionDraft(question)); +}; + +const SurveyEditorDialog: React.FC = ({ + isOpen, + survey, + onClose, + onSaved, +}) => { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [isActive, setIsActive] = useState(true); + const [timeToComplete, setTimeToComplete] = useState('0'); + const [questions, setQuestions] = useState([ + createQuestionDraft(), + ]); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!isOpen || !survey) { + return; + } + + setTitle(survey.title || ''); + setDescription(survey.description || ''); + setIsActive(survey.isActive ?? true); + setTimeToComplete(String(survey.timeToComplete ?? 0)); + setQuestions(buildInitialQuestions(survey)); + }, [isOpen, survey]); + + const totalQuestions = useMemo(() => questions.length, [questions.length]); + + const handleAddQuestion = () => { + setQuestions(previous => [...previous, createQuestionDraft()]); + }; + + const handleRemoveQuestion = (index: number) => { + setQuestions(previous => { + if (previous.length <= 1) { + return [createQuestionDraft()]; + } + + return previous.filter((_, currentIndex) => currentIndex !== index); + }); + }; + + const handleQuestionChange = ( + index: number, + field: keyof SurveyQuestionDraft, + value: string | boolean + ) => { + setQuestions(previous => + previous.map((question, currentIndex) => { + if (currentIndex !== index) { + return question; + } + + return { + ...question, + [field]: value, + }; + }) + ); + }; + + const handleSubmit = async () => { + const normalizedTitle = title.trim(); + const normalizedDescription = description.trim(); + const normalizedTimeToComplete = Number(timeToComplete); + + if (!survey) { + toast.error('Survey data is not available'); + return; + } + + if (!normalizedTitle) { + toast.error('Survey title is required'); + return; + } + + if ( + !Number.isFinite(normalizedTimeToComplete) || + normalizedTimeToComplete < 0 + ) { + toast.error('Enter a valid time to complete in seconds'); + return; + } + + const validationError = questions.find((question, index) => { + if (!question.question.trim()) { + toast.error(`Question ${index + 1} needs text`); + return true; + } + + if (question.type === 'multipleChoice') { + const optionCount = question.optionsText + .split(/\r?\n/) + .map(option => option.trim()) + .filter(Boolean).length; + + if (optionCount < 2) { + toast.error( + `Question ${index + 1} needs at least two options for multiple choice` + ); + return true; + } + } + + if (question.type === 'rating') { + const minValue = Number(question.minValue); + const maxValue = Number(question.maxValue); + + if ( + !Number.isFinite(minValue) || + !Number.isFinite(maxValue) || + minValue > maxValue + ) { + toast.error(`Question ${index + 1} needs a valid rating range`); + return true; + } + } + + return false; + }); + + if (validationError) { + return; + } + + setIsSaving(true); + + try { + const updatedSurvey = await surveyService.updateSurvey(survey._id, { + title: normalizedTitle, + description: normalizedDescription, + isActive, + timeToComplete: normalizedTimeToComplete, + questions: questions.map(question => serializeQuestionDraft(question)), + }); + + toast.success('Survey updated successfully'); + await onSaved(updatedSurvey); + onClose(); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } finally { + setIsSaving(false); + } + }; + + return ( + +
+
+ setTitle(e.target.value)} + placeholder="Air quality awareness survey" + required + /> + + setDescription(e.target.value)} + placeholder="Short description that explains the survey purpose." + rows={4} + /> +
+ +
+ setTimeToComplete(String(e.target.value))} + description="Used to estimate completion effort." + /> + +
+
+ setIsActive(Boolean(checked))} + className="mt-0.5" + /> +
+

+ Active survey +

+

+ Active surveys remain visible in the system list and can keep + collecting responses. +

+
+
+
+
+ +
+
+
+

Questions

+

+ {totalQuestions} question + {totalQuestions === 1 ? '' : 's'} in this survey. Use one option + per line for multiple choice questions. +

+
+ + +
+ +
+ {questions.map((question, index) => { + const isMultipleChoice = question.type === 'multipleChoice'; + const isRating = question.type === 'rating'; + + return ( +
+
+
+

+ Question {index + 1} +

+

+ {formatQuestionTypeLabel(question.type)} +

+
+ + +
+ +
+
+ + handleQuestionChange( + index, + 'question', + e.target.value + ) + } + placeholder="What was your primary activity when you received this survey?" + rows={3} + required + /> +
+ + + +
+
+ + handleQuestionChange( + index, + 'isRequired', + Boolean(checked) + ) + } + className="mt-0.5" + /> +
+

+ Required response +

+

+ Required questions must be answered before the + survey can be completed. +

+
+
+
+ + {isRating && ( + <> + + handleQuestionChange( + index, + 'minValue', + e.target.value + ) + } + description="Lowest score allowed in this question." + /> + + handleQuestionChange( + index, + 'maxValue', + e.target.value + ) + } + description="Highest score allowed in this question." + /> + + )} + + {isMultipleChoice && ( +
+ + handleQuestionChange( + index, + 'optionsText', + e.target.value + ) + } + placeholder={`Option one\nOption two\nOption three`} + rows={4} + /> +

+ Enter one option per line. +

+
+ )} +
+
+ ); + })} +
+
+
+
+ ); +}; + +export default SurveyEditorDialog; diff --git a/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx new file mode 100644 index 0000000000..2fb81ae037 --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyForm.tsx @@ -0,0 +1,788 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { AqArrowLeft, AqPlus, AqTrash01 } from '@airqo/icons-react'; +import type { + CreateSurveyRequest, + Survey, + UpdateSurveyRequest, +} from '@/shared/types/api'; +import { + Button, + Card, + Checkbox, + Input, + PageHeading, + Select, + TextInput, + toast, +} from '@/shared/components/ui'; +import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import { + createQuestionDraft, + createTriggerDraft, + formatDateTime, + formatDuration, + formatQuestionTypeLabel, + getSurveyTriggerTypeLabel, + SURVEY_QUESTION_TYPES, + SURVEY_TRIGGER_TYPES, + serializeQuestionDraft, + serializeTriggerDraft, + type SurveyQuestionDraft, + type SurveyTriggerDraft, +} from '../utils'; + +type SurveyPayload = CreateSurveyRequest | UpdateSurveyRequest; + +interface SurveyFormProps { + mode: 'create' | 'edit'; + initialSurvey?: Survey | null; + onCancel: () => void; + onSubmit: (payload: SurveyPayload) => Promise; + onSuccess: (survey: Survey) => Promise | void; +} + +const formatDateTimeLocal = (value?: string | null): string => { + if (!value) { + return ''; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return ''; + } + + const offset = parsed.getTimezoneOffset() * 60000; + const localDate = new Date(parsed.getTime() - offset); + return localDate.toISOString().slice(0, 16); +}; + +const buildQuestionErrors = (question: SurveyQuestionDraft): string[] => { + const errors: string[] = []; + + if (!question.question.trim()) { + errors.push('Question text is required'); + } + + if (question.type === 'multipleChoice') { + const optionCount = question.optionsText + .split(/\r?\n/) + .map(option => option.trim()) + .filter(Boolean).length; + + if (optionCount < 2) { + errors.push('Add at least two options for multiple choice'); + } + } + + if (question.type === 'rating' || question.type === 'scale') { + const minValue = Number(question.minValue); + const maxValue = Number(question.maxValue); + + if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) { + errors.push('Provide a valid rating range'); + } else if (minValue > maxValue) { + errors.push('Minimum value must be less than or equal to maximum'); + } + } + + return errors; +}; + +const SurveyForm: React.FC = ({ + mode, + initialSurvey, + onCancel, + onSubmit, + onSuccess, +}) => { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [isActive, setIsActive] = useState(true); + const [timeToComplete, setTimeToComplete] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); + const [triggerDraft, setTriggerDraft] = + useState(createTriggerDraft()); + const [questions, setQuestions] = useState([ + createQuestionDraft(), + ]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [fieldErrors, setFieldErrors] = useState>({}); + + useEffect(() => { + if (mode === 'edit' && initialSurvey) { + setTitle(initialSurvey.title || ''); + setDescription(initialSurvey.description || ''); + setIsActive(initialSurvey.isActive ?? true); + setTimeToComplete(String(initialSurvey.timeToComplete ?? '')); + setExpiresAt(formatDateTimeLocal(initialSurvey.expiresAt)); + setTriggerDraft(createTriggerDraft(initialSurvey.trigger || undefined)); + setQuestions( + initialSurvey.questions.length > 0 + ? initialSurvey.questions.map(question => + createQuestionDraft(question) + ) + : [createQuestionDraft()] + ); + return; + } + + if (mode === 'create') { + setTitle(''); + setDescription(''); + setIsActive(true); + setTimeToComplete('180'); + setExpiresAt(''); + setTriggerDraft(createTriggerDraft()); + setQuestions([createQuestionDraft()]); + } + }, [initialSurvey, mode]); + + const questionSummary = useMemo(() => { + const total = questions.length; + const required = questions.filter(question => question.isRequired).length; + return { total, required }; + }, [questions]); + + const handleAddQuestion = () => { + setQuestions(previous => [...previous, createQuestionDraft()]); + }; + + const handleRemoveQuestion = (index: number) => { + setQuestions(previous => { + if (previous.length <= 1) { + return [createQuestionDraft()]; + } + + return previous.filter((_, currentIndex) => currentIndex !== index); + }); + }; + + const handleQuestionChange = ( + index: number, + field: keyof SurveyQuestionDraft, + value: string | boolean + ) => { + setQuestions(previous => + previous.map((question, currentIndex) => + currentIndex === index ? { ...question, [field]: value } : question + ) + ); + }; + + const handleSubmit = async () => { + const normalizedTitle = title.trim(); + const normalizedDescription = description.trim(); + const normalizedTimeToComplete = Number(timeToComplete); + const normalizedExpiresAt = expiresAt + ? new Date(expiresAt).toISOString() + : undefined; + const serializedQuestions = questions.map(question => + serializeQuestionDraft(question) + ); + const serializedTrigger = serializeTriggerDraft(triggerDraft); + + const nextErrors: Record = {}; + + if (!normalizedTitle) { + nextErrors.title = ['Survey title is required']; + } + + if ( + !Number.isFinite(normalizedTimeToComplete) || + normalizedTimeToComplete < 0 + ) { + nextErrors.timeToComplete = ['Enter a valid completion time in seconds']; + } + + if (triggerDraft.type === 'postExposure') { + const threshold = Number(triggerDraft.threshold); + const duration = Number(triggerDraft.duration); + + if (!Number.isFinite(threshold) || threshold < 0) { + nextErrors.trigger = [ + ...(nextErrors.trigger || []), + 'Threshold must be a valid number', + ]; + } + + if (!Number.isFinite(duration) || duration <= 0) { + nextErrors.trigger = [ + ...(nextErrors.trigger || []), + 'Duration must be greater than zero', + ]; + } + } + + questions.forEach((question, index) => { + const errors = buildQuestionErrors(question); + if (errors.length > 0) { + nextErrors[`question-${index}`] = errors; + } + }); + + setFieldErrors(nextErrors); + + if (Object.keys(nextErrors).length > 0) { + const firstMessage = + nextErrors.title?.[0] || + nextErrors.timeToComplete?.[0] || + nextErrors.trigger?.[0] || + nextErrors['question-0']?.[0] || + 'Please fix the highlighted errors'; + toast.error(firstMessage); + return; + } + + setIsSubmitting(true); + try { + const payload: SurveyPayload = { + title: normalizedTitle, + description: normalizedDescription, + isActive, + timeToComplete: normalizedTimeToComplete, + expiresAt: normalizedExpiresAt, + trigger: serializedTrigger, + questions: serializedQuestions, + }; + + const savedSurvey = await onSubmit(payload); + toast.success( + mode === 'edit' + ? 'Survey updated successfully' + : 'Survey created successfully' + ); + await onSuccess(savedSurvey); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + }; + + const titleError = fieldErrors.title?.[0]; + const timeToCompleteError = fieldErrors.timeToComplete?.[0]; + const triggerError = fieldErrors.trigger?.[0]; + + return ( +
+
+ +
+ + + + +
+ } + > +
+ + {questionSummary.total} questions + + + {questionSummary.required} required + + + {isActive ? 'Active' : 'Inactive'} + +
+ + +
+
+ +
+
+

+ Survey details +

+

+ Title, description, lifecycle, and timing. +

+
+ + setTitle(e.target.value)} + placeholder="Air Quality Exposure Assessment" + required + error={titleError} + /> + + setDescription(e.target.value)} + placeholder="Help us understand how air quality affects your daily activities and health." + rows={4} + /> + +
+ setTimeToComplete(String(e.target.value))} + description="Estimate the time respondents should need to finish." + error={timeToCompleteError} + /> + + setExpiresAt(String(e.target.value))} + description="Optional end date for the survey." + /> +
+ +
+
+ setIsActive(Boolean(checked))} + className="mt-0.5" + /> +
+

+ Survey active +

+

+ Active surveys can be shown to respondents immediately. +

+
+
+
+
+
+ + +
+
+
+

+ Trigger +

+

+ Choose when the survey should be delivered. +

+
+
+ +
+ + +
+

+ Current trigger +

+

+ {getSurveyTriggerTypeLabel(triggerDraft.type)} +

+ {triggerDraft.type === 'postExposure' ? ( +

+ Deliver the survey when air quality rises above the + threshold for the configured duration. +

+ ) : ( +

+ Manual trigger keeps the survey controlled by the admin. +

+ )} +
+
+ + {triggerDraft.type === 'postExposure' && ( +
+ + setTriggerDraft(previous => ({ + ...previous, + threshold: String(e.target.value), + })) + } + description="Example: 50" + error={triggerError} + /> + + setTriggerDraft(previous => ({ + ...previous, + duration: String(e.target.value), + })) + } + description="How long the threshold must stay elevated." + error={triggerError} + /> +
+ )} +
+
+ + +
+
+
+

+ Questions +

+

+ Build the survey structure. One option per line for multiple + choice questions. +

+
+ +
+ +
+ {questions.map((question, index) => { + const isMultipleChoice = question.type === 'multipleChoice'; + const isRating = question.type === 'rating'; + const isScale = question.type === 'scale'; + const isYesNo = question.type === 'yesNo'; + const questionErrors = fieldErrors[`question-${index}`] || []; + + return ( +
+
+
+

+ Question {index + 1} +

+

+ {formatQuestionTypeLabel(question.type)} +

+
+ +
+ +
+
+ + handleQuestionChange( + index, + 'question', + e.target.value + ) + } + placeholder="What activity were you doing when you received this survey?" + rows={3} + required + /> +
+ + + +
+
+ + handleQuestionChange( + index, + 'isRequired', + Boolean(checked) + ) + } + className="mt-0.5" + /> +
+

+ Required response +

+

+ Required questions block completion until + answered. +

+
+
+
+ + {isRating || isScale ? ( + <> + + handleQuestionChange( + index, + 'minValue', + e.target.value + ) + } + description="Lowest allowed value." + /> + + handleQuestionChange( + index, + 'maxValue', + e.target.value + ) + } + description="Highest allowed value." + /> + + ) : null} + + {question.type === 'text' && ( +
+ + handleQuestionChange( + index, + 'placeholder', + e.target.value + ) + } + placeholder="Enter your answer here..." + description="Shown inside the response field." + /> +
+ )} + + {isMultipleChoice && ( +
+ + handleQuestionChange( + index, + 'optionsText', + e.target.value + ) + } + placeholder={`Option one\nOption two\nOption three`} + rows={4} + /> +

+ Enter one option per line. +

+
+ )} + + {isYesNo && ( +
+ This question will render as a fixed Yes / No + choice. +
+ )} +
+ + {questionErrors.length > 0 && ( +
+
    + {questionErrors.map(error => ( +
  • {error}
  • + ))} +
+
+ )} +
+ ); + })} +
+
+
+ + +
+ + +
+
+
+ +
+ +
+
+

+ Preview +

+

+ What admins will see in the survey list and details view. +

+
+ +
+

+ Title +

+

+ {title || 'Untitled survey'} +

+

+ {description || 'No description provided yet.'} +

+
+ +
+
+

+ Estimated time +

+

+ {formatDuration(Number(timeToComplete) || 0)} +

+
+
+

+ Trigger +

+

+ {getSurveyTriggerTypeLabel(triggerDraft.type)} +

+
+
+

+ Status +

+

+ {isActive ? 'Active' : 'Inactive'} +

+
+
+

+ Expires +

+

+ {expiresAt + ? formatDateTime(new Date(expiresAt).toISOString()) + : 'Not set'} +

+
+
+ + {mode === 'edit' && initialSurvey && ( +
+

+ Created {formatDateTime(initialSurvey.createdAt)} and last + updated {formatDateTime(initialSurvey.updatedAt)}. +

+
+ )} +
+
+
+
+
+ ); +}; + +export default SurveyForm; diff --git a/src/platform/src/app/(dashboard)/system/surveys/components/SurveyResponseDialog.tsx b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyResponseDialog.tsx new file mode 100644 index 0000000000..6b9168cbc0 --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/surveys/components/SurveyResponseDialog.tsx @@ -0,0 +1,204 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { Card, Dialog } from '@/shared/components/ui'; +import type { Survey, SurveyResponseItem } from '@/shared/types/api'; +import { + formatDateTime, + formatDuration, + formatQuestionTypeLabel, + formatResponseValue, +} from '../utils'; + +interface SurveyResponseDialogProps { + isOpen: boolean; + onClose: () => void; + response: SurveyResponseItem | null; + survey: Survey | null; +} + +const DetailChip: React.FC<{ + label: string; + value: React.ReactNode; +}> = ({ label, value }) => ( +
+

+ {label} +

+
{value}
+
+); + +const SurveyResponseDialog: React.FC = ({ + isOpen, + onClose, + response, + survey, +}) => { + const questionLookup = useMemo(() => { + return new Map( + survey?.questions?.map(question => [question.id, question]) || [] + ); + }, [survey?.questions]); + + const statusLabel = response?.status + ? formatQuestionTypeLabel(response.status) + : 'Unknown'; + + return ( + + {response ? ( +
+
+ + {statusLabel} + + } + /> + +

{response.user?.email || response.userId || 'Unknown'}

+

+ {response.user + ? `${response.user.firstName || ''} ${response.user.lastName || ''}`.trim() || + 'No user profile attached' + : response.isGuest + ? 'Guest submission' + : 'No user profile attached'} +

+
+ } + /> + + + + +
+ + +
+ + {response.deviceId || 'Not tracked'} + + } + /> + + {response.userId || 'Not tracked'} + + } + /> + + +
+
+ +
+
+

+ Answers +

+

+ Answers are shown in the order they were submitted. +

+
+ +
+ {response.answers.length > 0 ? ( + response.answers.map(answer => { + const question = questionLookup.get(answer.questionId); + + return ( + +
+
+

+ {question?.question || + `Question ${answer.questionId}`} +

+
+ + {question + ? formatQuestionTypeLabel(question.type) + : 'Unknown type'} + + {answer.questionId} +
+
+ +
+

+ Answer +

+

+ {formatResponseValue(answer.answer)} +

+

+ Answered at {formatDateTime(answer.answeredAt)} +

+
+
+
+ ); + }) + ) : ( + +

+ No answers were captured for this submission. +

+
+ )} +
+
+ + ) : null} +
+ ); +}; + +export default SurveyResponseDialog; diff --git a/src/platform/src/app/(dashboard)/system/surveys/new/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/new/page.tsx new file mode 100644 index 0000000000..9ccb3b785e --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/surveys/new/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React, { useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { useSWRConfig } from 'swr'; +import { PermissionGuard } from '@/shared/components'; +import type { + CreateSurveyRequest, + Survey, + UpdateSurveyRequest, +} from '@/shared/types/api'; +import { surveyService } from '@/shared/services'; +import SurveyForm from '../components/SurveyForm'; + +const SurveyCreatePage: React.FC = () => { + const router = useRouter(); + const { mutate } = useSWRConfig(); + + const handleCancel = useCallback(() => { + router.push('/system/surveys'); + }, [router]); + + const handleSubmit = useCallback( + async (payload: CreateSurveyRequest | UpdateSurveyRequest) => { + return surveyService.createSurvey(payload as CreateSurveyRequest); + }, + [] + ); + + const handleSuccess = useCallback( + async (survey: Survey) => { + await mutate('system/surveys'); + + if (survey?._id) { + router.replace(`/system/surveys/${survey._id}`); + return; + } + + router.replace('/system/surveys'); + }, + [mutate, router] + ); + + return ( + + + + ); +}; + +export default SurveyCreatePage; diff --git a/src/platform/src/app/(dashboard)/system/surveys/page.tsx b/src/platform/src/app/(dashboard)/system/surveys/page.tsx new file mode 100644 index 0000000000..fea4bc5e51 --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/surveys/page.tsx @@ -0,0 +1,294 @@ +'use client'; + +import React, { useMemo, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import useSWR from 'swr'; +import { PermissionGuard } from '@/shared/components'; +import { + Button, + Card, + EmptyState, + LoadingState, + PageHeading, + toast, +} from '@/shared/components/ui'; +import { AqEye, AqPlus, AqRefreshCw05 } from '@airqo/icons-react'; +import { surveyService } from '@/shared/services'; +import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import { refreshWithToast } from '@/shared/utils/refreshWithToast'; +import type { Survey } from '@/shared/types/api'; +import { + formatDateTime, + formatDuration, + getSurveyTriggerLabel, + formatSurveyStatus, +} from './utils'; + +const SurveyListPage: React.FC = () => { + const router = useRouter(); + + const { + data: surveys = [], + error, + isLoading, + mutate, + isValidating, + } = useSWR('system/surveys', () => surveyService.getActiveSurveys(), { + revalidateOnFocus: false, + shouldRetryOnError: false, + }); + + const summary = useMemo(() => { + const totalQuestions = surveys.reduce( + (sum, survey) => sum + (survey.questionCount ?? survey.questions.length), + 0 + ); + const requiredQuestions = surveys.reduce( + (sum, survey) => + sum + + (survey.requiredQuestionCount ?? + survey.questions.filter(question => question.isRequired).length), + 0 + ); + const averageCompletionTime = + surveys.length > 0 + ? Math.round( + surveys.reduce((sum, survey) => sum + survey.timeToComplete, 0) / + surveys.length + ) + : 0; + const activeSurveys = surveys.filter(survey => survey.isActive !== false); + + return { + activeSurveys: activeSurveys.length, + totalQuestions, + requiredQuestions, + averageCompletionTime, + }; + }, [surveys]); + + const handleOpenSurvey = useCallback( + (surveyId: string) => { + router.push(`/system/surveys/${surveyId}`); + }, + [router] + ); + + const handleCreateSurvey = useCallback(() => { + router.push('/system/surveys/new'); + }, [router]); + + const handleRefresh = useCallback(async () => { + try { + await refreshWithToast( + () => mutate(), + 'Survey list refreshed successfully' + ); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } + }, [mutate]); + + const renderSurveyCard = useCallback( + (survey: Survey) => { + const status = formatSurveyStatus( + survey.isActive, + survey.surveyStatus, + survey.isExpired + ); + + const questionCount = survey.questionCount ?? survey.questions.length; + const requiredCount = + survey.requiredQuestionCount ?? + survey.questions.filter(question => question.isRequired).length; + + return ( + +
+
+
+ + {status.label} + + + {questionCount} question{questionCount === 1 ? '' : 's'} + + + {requiredCount} required + +
+ +
+

+ {survey.title} +

+

+ {survey.description || 'No description provided.'} +

+
+
+
+ +
+
+

+ Estimated time +

+

+ {formatDuration(survey.timeToComplete)} +

+
+
+

+ Updated +

+

+ {formatDateTime(survey.updatedAt)} +

+
+
+

+ Created +

+

+ {formatDateTime(survey.createdAt)} +

+
+
+

+ Expires +

+

+ {formatDateTime(survey.expiresAt)} +

+
+
+ +
+

+ Trigger: {getSurveyTriggerLabel(survey.trigger)} +

+ + +
+
+ ); + }, + [handleOpenSurvey] + ); + + return ( + +
+ + + +
+ } + /> + +
+ +

Active surveys

+

+ {summary.activeSurveys} +

+

+ Available to the system team right now. +

+
+ + +

Total questions

+

+ {summary.totalQuestions} +

+

+ Across all active surveys. +

+
+ + +

Required questions

+

+ {summary.requiredQuestions} +

+

+ Must be answered before completion. +

+
+ + +

+ Avg. completion time +

+

+ {formatDuration(summary.averageCompletionTime)} +

+

+ Average across active surveys. +

+
+
+ + {isLoading ? ( + + ) : error ? ( + +

+ {getUserFriendlyErrorMessage(error)} +

+
+ ) : surveys.length === 0 ? ( + + ) : ( +
+ {surveys.map(renderSurveyCard)} +
+ )} + +
+ ); +}; + +export default SurveyListPage; diff --git a/src/platform/src/app/(dashboard)/system/surveys/utils.ts b/src/platform/src/app/(dashboard)/system/surveys/utils.ts new file mode 100644 index 0000000000..547bafd091 --- /dev/null +++ b/src/platform/src/app/(dashboard)/system/surveys/utils.ts @@ -0,0 +1,287 @@ +import type { SurveyQuestion, SurveyTrigger } from '@/shared/types/api'; + +export const SURVEY_QUESTION_TYPES = [ + { value: 'text', label: 'Text' }, + { value: 'multipleChoice', label: 'Multiple choice' }, + { value: 'rating', label: 'Rating' }, + { value: 'yesNo', label: 'Yes / No' }, + { value: 'scale', label: 'Scale' }, +] as const; + +export const SURVEY_TRIGGER_TYPES = [ + { value: 'manual', label: 'Manual' }, + { value: 'postExposure', label: 'Post-exposure' }, + { value: 'scheduled', label: 'Scheduled' }, +] as const; + +export type SurveyQuestionDraft = { + id: string; + question: string; + type: string; + optionsText: string; + placeholder: string; + isRequired: boolean; + minValue: string; + maxValue: string; +}; + +export type SurveyTriggerDraft = { + type: string; + threshold: string; + duration: string; +}; + +export const createQuestionDraft = ( + question?: SurveyQuestion +): SurveyQuestionDraft => ({ + id: + question?.id || + `question_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + question: question?.question || '', + type: question?.type || 'text', + optionsText: (question?.options || []).join('\n'), + placeholder: question?.placeholder || '', + isRequired: question?.isRequired ?? true, + minValue: question?.minValue != null ? String(question.minValue) : '', + maxValue: question?.maxValue != null ? String(question.maxValue) : '', +}); + +export const createTriggerDraft = ( + trigger?: SurveyTrigger | null +): SurveyTriggerDraft => ({ + type: trigger?.type || 'manual', + threshold: + typeof trigger?.conditions?.threshold === 'number' + ? String(trigger.conditions.threshold) + : '', + duration: + typeof trigger?.conditions?.duration === 'number' + ? String(trigger.conditions.duration) + : '', +}); + +export const serializeQuestionDraft = ( + draft: SurveyQuestionDraft +): SurveyQuestion => { + const typedOptions = + draft.type === 'multipleChoice' + ? draft.optionsText + .split(/\r?\n/) + .map(option => option.trim()) + .filter(Boolean) + : draft.type === 'yesNo' + ? ['Yes', 'No'] + : []; + + const minValue = + (draft.type === 'rating' || draft.type === 'scale') && + draft.minValue.trim() !== '' + ? Number(draft.minValue) + : undefined; + const maxValue = + (draft.type === 'rating' || draft.type === 'scale') && + draft.maxValue.trim() !== '' + ? Number(draft.maxValue) + : undefined; + + return { + id: draft.id.trim() || `question_${Date.now()}`, + question: draft.question.trim(), + type: draft.type.trim() || 'text', + options: typedOptions, + isRequired: draft.isRequired, + placeholder: draft.placeholder.trim() || undefined, + ...(Number.isFinite(minValue) ? { minValue } : {}), + ...(Number.isFinite(maxValue) ? { maxValue } : {}), + }; +}; + +export const serializeTriggerDraft = ( + draft: SurveyTriggerDraft +): SurveyTrigger => { + const threshold = + draft.threshold.trim() !== '' ? Number(draft.threshold) : undefined; + const duration = + draft.duration.trim() !== '' ? Number(draft.duration) : undefined; + + const conditions: Record = {}; + if (Number.isFinite(threshold)) { + conditions.threshold = Number(threshold); + } + if (Number.isFinite(duration)) { + conditions.duration = Number(duration); + } + + return { + type: draft.type.trim() || 'manual', + ...(Object.keys(conditions).length > 0 ? { conditions } : {}), + }; +}; + +export const formatDateTime = (value?: string | null): string => { + if (!value) { + return 'Not available'; + } + + const parsedDate = new Date(value); + if (Number.isNaN(parsedDate.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(parsedDate); +}; + +export const formatDuration = (seconds?: number | null): string => { + if (seconds == null || Number.isNaN(Number(seconds))) { + return 'Not available'; + } + + const totalSeconds = Math.max(0, Math.round(Number(seconds))); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + + const minutes = Math.floor(totalSeconds / 60); + const remainingSeconds = totalSeconds % 60; + + if (minutes < 60) { + return remainingSeconds > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${minutes}m`; + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +}; + +export const formatQuestionTypeLabel = (type: string): string => { + switch (type) { + case 'text': + return 'Text'; + case 'multipleChoice': + return 'Multiple choice'; + case 'rating': + return 'Rating'; + case 'yesNo': + return 'Yes / No'; + case 'scale': + return 'Scale'; + default: + return type + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()); + } +}; + +export const formatResponseValue = (value: unknown): string => { + if (Array.isArray(value)) { + return value.map(item => String(item)).join(', '); + } + + if (value === null || value === undefined || value === '') { + return 'Not answered'; + } + + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + + return String(value); +}; + +export const getTopAnswerEntries = ( + distribution?: Record, + limit = 3 +): Array<[string, number]> => { + if (!distribution) { + return []; + } + + return Object.entries(distribution) + .sort((left, right) => right[1] - left[1]) + .slice(0, limit); +}; + +export const getTotalAnswerCount = ( + distribution?: Record +): number => { + if (!distribution) { + return 0; + } + + return Object.values(distribution).reduce( + (sum, value) => sum + Number(value || 0), + 0 + ); +}; + +export const formatSurveyStatus = ( + isActive?: boolean, + surveyStatus?: string, + isExpired?: boolean +): { label: string; tone: string } => { + if (isExpired) { + return { + label: 'Expired', + tone: 'bg-slate-100 text-slate-700 dark:bg-slate-950/40 dark:text-slate-300', + }; + } + + if (isActive === false) { + return { + label: surveyStatus ? formatQuestionTypeLabel(surveyStatus) : 'Inactive', + tone: 'bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300', + }; + } + + if (surveyStatus) { + return { + label: formatQuestionTypeLabel(surveyStatus), + tone: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300', + }; + } + + return { + label: isActive ? 'Active' : 'Inactive', + tone: isActive + ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300' + : 'bg-slate-100 text-slate-700 dark:bg-slate-950/40 dark:text-slate-300', + }; +}; + +export const getQuestionDistribution = ( + distribution: Record> | undefined, + questionId: string +): Record | undefined => { + return distribution?.[questionId]; +}; + +export const getSurveyTriggerLabel = ( + trigger?: SurveyTrigger | null +): string => { + if (!trigger) { + return 'Manual trigger'; + } + + const triggerType = trigger.type; + if (!triggerType) { + return 'Manual trigger'; + } + + const label = getSurveyTriggerTypeLabel(String(triggerType)); + return label === 'Manual' ? 'Manual trigger' : label; +}; + +export const getSurveyTriggerTypeLabel = (type: string): string => { + const match = SURVEY_TRIGGER_TYPES.find(option => option.value === type); + return match?.label || formatQuestionTypeLabel(type); +}; diff --git a/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx b/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx index 24bb131b88..4f6e7fdbc5 100644 --- a/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/user-statistics/[id]/page.tsx @@ -27,6 +27,7 @@ import { } from '@airqo/icons-react'; import { formatWithPattern } from '@/shared/utils/dateUtils'; import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; +import { refreshWithToast } from '@/shared/utils/refreshWithToast'; const UserStatisticsDetailsPage: React.FC = () => { const params = useParams(); @@ -116,9 +117,15 @@ const UserStatisticsDetailsPage: React.FC = () => { router.push('/system/user-statistics'); }, [router]); - const handleRefresh = useCallback(() => { - mutateUser(); - mutateRoles(); + const handleRefresh = useCallback(async () => { + try { + await refreshWithToast( + () => Promise.all([mutateUser(), mutateRoles()]), + 'User details refreshed successfully' + ); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } }, [mutateRoles, mutateUser]); const openRoleDialog = useCallback(() => { diff --git a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx index 2b23303ad7..9c24bd6ac1 100644 --- a/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx +++ b/src/platform/src/app/(dashboard)/system/user-statistics/page.tsx @@ -13,6 +13,8 @@ import { Card } from '@/shared/components/ui/card'; import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; import type { UserStatisticsUser } from '@/shared/types/api'; +import { toast } from '@/shared/components/ui/toast'; +import { refreshWithToast } from '@/shared/utils/refreshWithToast'; import { DropdownMenu, DropdownMenuContent, @@ -309,9 +311,20 @@ const UserStatisticsPage: React.FC = () => { ); }; - const handleRefresh = () => { - mutate(); - }; + const handleRefresh = useCallback(async () => { + try { + await refreshWithToast( + () => mutate(), + 'User statistics refreshed successfully' + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : 'Unable to refresh user statistics' + ); + } + }, [mutate]); if (isLoading) { return ( diff --git a/src/platform/src/app/api/users/tokens/security/route.ts b/src/platform/src/app/api/users/tokens/security/route.ts new file mode 100644 index 0000000000..265cc1bd5a --- /dev/null +++ b/src/platform/src/app/api/users/tokens/security/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/shared/lib/auth'; +import { buildServerApiUrl } from '@/shared/lib/api-routing'; +import { normalizeOAuthAccessToken } from '@/shared/lib/oauth-session'; +import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging'; +import type { Session } from 'next-auth'; +import type { UpdateTokenSecurityRequest } from '@/shared/types/api'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; +const DEFAULT_PROXY_TIMEOUT_MS = 30000; + +type TokenSecurityProxyRequest = UpdateTokenSecurityRequest & { + token?: string; +}; + +const resolveAuthorizationHeader = ( + request: NextRequest, + session: Session | null +): string | null => { + const authorizationHeader = request.headers.get('authorization'); + if (authorizationHeader?.trim()) { + return authorizationHeader; + } + + const accessToken = normalizeOAuthAccessToken( + (session as { accessToken?: string } | null)?.accessToken || '' + ); + + return accessToken ? `JWT ${accessToken}` : null; +}; + +export async function PATCH(request: NextRequest) { + try { + const session = (await getServerSession(authOptions)) as Session | null; + const authorizationHeader = resolveAuthorizationHeader(request, session); + + if (!authorizationHeader) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: TokenSecurityProxyRequest; + try { + body = (await request.json()) as TokenSecurityProxyRequest; + } catch { + return NextResponse.json( + { error: 'Invalid JSON payload' }, + { status: 400 } + ); + } + + const token = typeof body.token === 'string' ? body.token.trim() : ''; + if (!token) { + return NextResponse.json({ error: 'Missing token' }, { status: 400 }); + } + + const payload = { ...body }; + delete payload.token; + const upstreamUrl = buildServerApiUrl( + `/users/tokens/${encodeURIComponent(token)}` + ); + + const upstreamAbortController = new AbortController(); + const timeoutId = setTimeout(() => { + upstreamAbortController.abort(); + }, DEFAULT_PROXY_TIMEOUT_MS); + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(upstreamUrl, { + method: 'PATCH', + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + Authorization: authorizationHeader, + }, + body: JSON.stringify(payload), + signal: upstreamAbortController.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + const responseBody = await upstreamResponse.text(); + + return new NextResponse(responseBody, { + status: upstreamResponse.status, + headers: { + 'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate', + 'Content-Type': + upstreamResponse.headers.get('Content-Type') || 'application/json', + Expires: '0', + Pragma: 'no-cache', + }, + }); + } catch (error) { + if ((error as { name?: string })?.name === 'AbortError') { + return NextResponse.json( + { error: 'Upstream request timed out' }, + { status: 504 } + ); + } + + console.error( + 'Token security proxy error:', + sanitizeErrorForLogging(error) + ); + return NextResponse.json( + { error: 'Failed to update token security' }, + { status: 500 } + ); + } +} diff --git a/src/platform/src/app/globals.css b/src/platform/src/app/globals.css index ad6e24e47a..24c4ed6890 100644 --- a/src/platform/src/app/globals.css +++ b/src/platform/src/app/globals.css @@ -92,7 +92,7 @@ body, html { color: rgb(var(--foreground)); background: rgb(var(--background)); - font-family: 'Inter', Arial, Helvetica, sans-serif; + font-family: var(--font-inter), Inter, Arial, Helvetica, sans-serif; } .dark body, diff --git a/src/platform/src/app/layout.tsx b/src/platform/src/app/layout.tsx index 1476b7da8b..5f28ca6bd1 100644 --- a/src/platform/src/app/layout.tsx +++ b/src/platform/src/app/layout.tsx @@ -1,6 +1,7 @@ import './globals.css'; import '@smastrom/react-rating/style.css'; import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; import Script from 'next/script'; import NextTopLoader from 'nextjs-toploader'; import { ReduxProvider } from '@/shared/providers/redux-provider'; @@ -15,6 +16,12 @@ import baseMetadata from '@/shared/lib/metadata'; import ErrorBoundary from '@/shared/components/ErrorBoundary'; import AppNetworkGate from '@/shared/components/AppNetworkGate'; +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}); + export const metadata: Metadata = baseMetadata; export default function RootLayout({ @@ -23,7 +30,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +