+ Added public data export API enhancements, tests, and customer documentation + Extended /api/v1 data export endpoints with richer session, records, area, and async export output + Added confirmed/fallback report values, client metadata, mapped area, over-spray, volume/apprate (string) units, and weather blocks + Normalized flowController to "No FC" and align record field names with playback output + Converted record wind speed output to knots, add Fligh Mater only record/export fields behind fm=true, and persist fm on export jobs + Added export status/area constants, HTTP 202 support, route-level API docs, and per-account export rate limiting support + Added comprehensive endpoint, format, and verification test coverage plus test-suite README + Added customer-facing data export design, integration, rate-limit, and documentation index guides + Updated README/DLQ docs and related documentation links to current HTTPS dashboard paths
35 KiB
Data Export API — Design & Implementation Guide
Single Source of Truth: This is the canonical document for Data Export API design, implementation status, and next steps in this branch.
Branch: data-export-api
Date: April 10, 2026
Status: Phase A complete — Phase B in progress
Change Log
| Date | Update |
|---|---|
| 2026-04-14 | Added ApiKeyServices and ExportUnits frozen constants to helpers/constants.js; wired throughout models and controllers. Added service field to ApiKey, units field to ExportJob, US unit conversion support to async export. |
| 2026-04-10 | Marked this file as the single source of truth for Data Export API design and implementation tracking. |
| 2026-04-10 | Consolidated documentation into this file and removed duplicate summary document. |
1. Overview
The Data Export API allows authorised external systems (data warehouses, Power BI, ArcGIS) to pull mission data from AgMission on demand or on a scheduled basis. It exposes the same data already shown in the web application's Data Playback screen, served through a versioned REST API authenticated with API keys.
Two functional areas:
- REST API (
/api/v1/) — session summaries, per-point GPS trace, spray-area polygons, async bulk export - UI enhancement — improved Job List filter controls (order number, date range) and an API Key management screen in the web app settings
2. Architecture
2.1 Request Flow & Authentication Architecture
sequenceDiagram
participant External as External System
participant API as Express Server
participant Auth as checkApiKey Middleware
participant DB as ApiKey DB
participant Handler as Route Handler
External->>API: GET /api/v1/jobs/:id/sessions
External->>API: Header X-API-Key
API->>Auth: req.headers x-api-key
Auth->>Auth: Extract prefix first 8 chars
Auth->>DB: Find by prefix active=true
DB-->>Auth: ApiKey candidates
Auth->>Auth: bcrypt.compare plainKey vs keyHash
Auth->>Auth: On match set req.uid
Auth->>Handler: next with req.uid set
Handler->>Handler: ownerJob verify req.uid
Handler-->>External: JSON response
Web UI caller (JWT-authenticated, unchanged):
graph LR
A[Web App] -->|Bearer token| B[checkUser Middleware]
B -->|Verify JWT| C[req.uid set]
C -->|api/keys routes| D[Key Management]
D -->|CRUD ops| E[ApiKey Model]
2.2 Data Model Hierarchy
graph TD
Job[Job model]
App[App - Session data]
AppFile[AppFile - Metadata]
AppDetail[AppDetail - GPS points]
ExportJob[ExportJob - Export tracker]
ApiKey[ApiKey - Authentication]
Job -->|has many| App
App -->|has many| AppFile
AppFile -->|has many| AppDetail
Job -.->|triggers| ExportJob
Job -.->|auth via| ApiKey
App -.->|derived from| AppDetail
style Job fill:#e1f5ff
style App fill:#f3e5f5
style AppFile fill:#fff3e0
style AppDetail fill:#fce4ec
style ExportJob fill:#e8f5e9
style ApiKey fill:#f1f8e9
Fields summary:
- Job: jobId, byPuid, rptOp, weatherInfo, sprayAreas
- App: avgSpraySpeed, totalSprayed, totalSprayTime, totalFlightTime
- AppFile: meta (operator, appRate, fcName, sprOnLag), totalSprayed, totalSprayTime
- AppDetail: gpsTime, lat, lon, grSpeed, lminApp, swath, sprayStat, windSpd, temp, humid
- ExportJob: owner, jobId, format, status, filePath, expiresAt
- ApiKey: owner, keyHash, prefix, active, lastUsedAt
2.3 Route Prefix Strategy
| Prefix | Auth | Purpose |
|---|---|---|
/api/v1/ |
X-API-Key header (new checkApiKey) |
Public data export endpoints |
/api/keys |
Authorization: Bearer (existing checkUser) |
Key management for web UI |
All other /api/... |
Authorization: Bearer (existing checkUser) |
Existing application routes — unchanged |
The /api/v1/ path is added to the checkUser bypass whitelist (in isSecuredRoute()) so the existing JWT middleware skips these routes.
2.4 Async Export Lifecycle
stateDiagram-v2
[*] --> pending: POST /export
pending --> processing: async generate
processing --> ready: success
processing --> error: fail I/O error
ready --> pending: download cleanup
error --> [*]: TTL expiry
ready --> [*]: 24h TTL
pending --> [*]: TTL index
Lifecycle details:
- pending: ExportJob created and returned to caller; caller polls GET /exports/:id
- processing: Streams AppDetail cursor to CSV or GeoJSON format (memory-efficient)
- ready: File written to disk at filePath, TTL (expiresAt) set and ready for download
- error: Error message recorded, awaits manual retry via queue or TTL cleanup
- Cleanup: After file download completes, filePath cleared and status reset to pending for potential re-download
3. New Files
Backend
| File | Status | Purpose |
|---|---|---|
model/api_key.js |
✅ Done | ApiKey Mongoose model |
model/export_job.js |
✅ Done | ExportJob tracking model |
middlewares/app_validator.js |
✅ Done | Added checkApiKey function + whitelist entry |
routes/api_pub.js |
✅ Done | /api/v1/ route definitions |
routes/api_keys.js |
✅ Done | /api/keys route definitions |
routes/index.js |
✅ Done | Registers api_pub and api_keys |
controllers/api_key.js |
✅ Done | createKey, listKeys, revokeKey |
controllers/api_pub.js |
✅ Done | getSessions, getSessionRecords, getAreas |
controllers/api_export.js |
✅ Done | triggerExport, getExportStatus, downloadExport |
scripts/migrate_avg_spray_speed.js |
✅ Done | One-time back-fill for existing jobs |
Modified Files (existing)
| File | Change |
|---|---|
model/application.js |
Added avgSpraySpeed: Number field |
workers/job_worker.js |
Accumulates avgSpraySpeed during file import at lines ~528, ~944–1090, ~1309–1381 |
Frontend (pending)
| File | Status | Purpose |
|---|---|---|
job-list.component.ts/.html |
⬜ Pending | Add orderNumber filter input |
src/app/settings/api-keys/ |
⬜ Pending | API Key management feature module |
4. Model Designs
4.1 ApiKey (model/api_key.js)
| Field | Type | Notes |
|---|---|---|
owner |
ObjectId → User | The applicator this key authorises |
label |
String | Human-readable name (max 100 chars) |
prefix |
String | First 8 chars of plain key — stored clear-text for O(1) candidate lookup |
keyHash |
String | bcryptjs hash of the full plain key — plain key never stored |
service |
ApiKeyServices |
Which service the key grants access to: 'data_export' (default) or 'partner_api' |
active |
Boolean | Revoke by setting false |
managedBy |
'owner' | 'admin' |
Who created the key |
createdAt |
Date | |
lastUsedAt |
Date | Updated async (fire-and-forget) — no added request latency |
Key lookup flow: prefix → find candidates → bcrypt.compare(incomingKey, candidate.keyHash) → match → set req.uid = key.owner.
Limit: 10 active keys per owner (enforced in createKey).
4.2 ExportJob (model/export_job.js)
| Field | Type | Notes |
|---|---|---|
owner |
ObjectId → User | Scoped to requesting applicator |
jobId |
Number | AgMission job ID |
format |
'csv' | 'geojson' |
Requested output format |
interval |
Number | null | GPS point thinning in seconds; null = all points |
units |
ExportUnits |
Output measurement system: 'metric' (default) or 'us' |
status |
'pending' | 'processing' | 'ready' | 'error' |
Lifecycle state |
filePath |
String | Absolute path on disk (set when ready) |
errorMsg |
String | Populated on error |
createdAt |
Date | |
expiresAt |
Date | MongoDB TTL index — document auto-deleted after expiry |
Files are written to env.TEMP_DIR. TTL defaults to 24 hours (EXPORT_TTL_HOURS env var).
5. API Endpoint Reference
5.1 Authentication
All /api/v1/ requests require:
X-API-Key: <full 64-char hex key>
No Authorization header needed. On failure the middleware returns 401.
5.2 GET /api/v1/jobs/:jobId/sessions
Returns one summary record per uploaded application file ("session") for the job.
Response shape (per session):
{
"sessionId": "...",
"fileName": "2507140724SatlocG4.log",
"startDateTime": "2025-07-14T10:24:00Z",
"endDateTime": "2025-07-14T11:05:42Z",
"totalFlightTime_s": 2462,
"totalSprayTime_s": 1840,
"totalTurnTime_s": 622,
"totalSprayed_ha": 48.3,
"totalSprayMat": 120.5,
"totalSprayMatUnit": 3,
"avgSpraySpeed_ms": 14.2,
"matType": "wet",
"appRate": 2.5,
"appRateUnit": "L/ha",
"flowController": "SatLoc G4",
"sprayOnLag_s": 0.2,
"sprayOffLag_s": 0.15,
"pulsesPerLiter": 1800,
"mappedArea_ha": 50.0,
"overSprayedPct": -3.4,
"sprayZoneName": "Field A North",
"sprayZoneArea_ha": 25.0,
"pilotId": "...",
"pilotName": "João Silva",
"aircraftName": "Agrinova 01",
"aircraftTailNumber": "PR-XYZ",
"assignedDate": "2025-07-13T18:00:00Z",
"sessionPilotName": "João Silva",
"reportConfirmed": true,
"areaSize_ha": 50.0,
"coverage_ha": 48.3,
"appRateConfirmed": 2.5,
"sprayVolume": 120.75,
"useActualVolume": false,
"actualVolume": null,
"effectiveVolume": 120.75,
"useCustomWeather": false,
"weather": null
}
Output field definitions (sessions endpoint)
Response envelope fields:
| Field | Type | Required | Description |
|---|---|---|---|
jobId |
number | ✓ | Numeric job identifier from the URL path. |
clientId |
string | null | — | Client account ObjectId (the applicator's customer this job was performed for). |
clientName |
string | null | — | Client account name. |
mappedArea_ha |
number | null | — | Sum of all planned spray polygon areas for the job. |
reportConfirmed |
boolean | ✓ | True when report settings are confirmed (rptOp.coverage != null). |
areaSize_ha |
number | null | — | Confirmed area size, or fallback mapped area when not confirmed. |
coverage_ha |
number | null | — | Confirmed coverage, or fallback total sprayed area across sessions. |
appRate |
number | null | — | Confirmed app rate, or first-session fallback app rate. |
appRateConfirmed |
number | null | — | Confirmed app rate only; null when not confirmed. |
sprayVolume |
number | null | — | coverage_ha × appRate when both are numeric. |
useActualVolume |
boolean | ✓ | True when applicator selected actual volume override. |
actualVolume |
number | null | — | Manual override volume when useActualVolume=true. |
effectiveVolume |
number | null | — | Authoritative volume: actual or calculated spray volume. |
useCustomWeather |
boolean | ✓ | True when custom weather was manually entered. |
weather |
object | null | — | Weather block when custom weather exists; otherwise null. |
customWeather |
object | null | — | Removed — was alias of weather; use weather. |
data |
array | ✓ | Array of per-session summary records. |
Per-session fields in data[]:
| Field | Type | Required | Description |
|---|---|---|
| sessionId | string | ✓ | Session identifier (App._id). |
| fileName | string | null | — | Session file name from App.fileName. |
| startDateTime | string | null | — | Session start datetime (ISO 8601 UTC). |
| endDateTime | string | null | — | Session end datetime (ISO 8601 UTC). |
| totalFlightTime_s | number | null | — | Total flight time in seconds. |
| totalSprayTime_s | number | null | — | Total spray time in seconds. |
| totalTurnTime_s | number | null | — | Total turn time in seconds. |
| totalSprayed_ha | number | null | — | Total sprayed area in hectares. |
| totalSprayMat | number | null | — | Total sprayed material amount. |
| totalSprayMatUnit | string | null | — | Spray material unit label (e.g. "lit", "kg") — decoded from raw code via rateUnitString(). |
| avgSpraySpeed_ms | number | null | — | Average spray speed in m/s. |
| sprayZoneName | string | null | — | Zone/area name from AppFile.meta.areaOrZone. |
| sprayZoneArea_ha | number | null | — | Zone area in hectares from AppFile.meta.sprCoverage[1]. |
| appRate | number | null | — | Session target app rate from file metadata. |
| appRateUnit | string | null | — | App rate unit label from job setting (canonical, matches top-level). |
| matType | string | null | — | Material type (for example wet/dry). |
| flowController | string | — | Flow controller name from file metadata. 'No FC' when absent or when the value is 'none' (case-insensitive), matching the playback display. |
| sprayOnLag_s | number | null | — | Spray-on lag in seconds. |
| sprayOffLag_s | number | null | — | Spray-off lag in seconds. |
| pulsesPerLiter | number | null | — | Pulses-per-liter. |
| files | array | ✓ | Session file list: [{ fileId, name }]. |
| sessionPilotName | string | null | — | Pilot name recorded inside the imported data file. |
| pilotId | string | null | — | Assigned pilot identifier from job operator relation. |
| pilotName | string | null | — | Pilot name (assigned pilot on the job record). |
| aircraftName | string | null | — | Assigned aircraft display name. |
| aircraftTailNumber | string | null | — | Assigned aircraft tail number. |
| assignedDate | string | null | — | Latest assignment timestamp (ISO 8601 UTC). |
| reportConfirmed | boolean | ✓ | Repeated from top-level for row-level convenience. |
| areaSize_ha | number | null | — | Repeated confirmed/fallback area size. |
| coverage_ha | number | null | — | Repeated confirmed/fallback coverage. |
| appRateConfirmed | number | null | — | Repeated confirmed app rate. |
| sprayVolume | number | null | — | Repeated spray volume. |
| volumeUnit | string | null | — | Volume unit label (e.g. "lit", "kg"). |
| useActualVolume | boolean | ✓ | Repeated actual-volume toggle. |
| actualVolume | number | null | — | Repeated actual volume override. |
| effectiveVolume | number | null | — | Repeated effective volume. |
reportConfirmed Fallback Logic Diagram:
flowchart TD
A{Is rptOp.coverage<br/>defined?}
A -->|Yes| B["reportConfirmed=true"]
A -->|No| C["reportConfirmed=false"]
B --> D["Use Report Settings<br/>values"]
C --> E["Compute from raw<br/>data"]
D --> F{useActualVol?}
E --> G{useActualVol?}
F -->|Yes| H["effective=actual"]
F -->|No| I["effective=coverage*rate"]
G -->|Yes| J["effective=computed"]
G -->|No| K["effective=computed"]
H --> L["Confirmed block"]
I --> L
J --> M["Fallback block"]
K --> M
| Field | reportConfirmed: true |
reportConfirmed: false |
|---|---|---|
areaSize_ha |
Job.rptOp.areaSize |
Sum of sprayAreas[].properties.area |
coverage_ha |
Job.rptOp.coverage |
Sum of App.totalSprayed |
appRate |
Job.rptOp.appRate |
AppFile.meta.appRate (first session) |
sprayVolume |
coverage × appRate (from rptOp) |
Same formula using fallback values |
effectiveVolume |
actualVol if useActualVol, else sprayVolume |
sprayVolume (fallback) |
| weather fields | Job.weatherInfo.* when useCustWI=true |
omitted |
When
reportConfirmed: false, re-fetch this record after the applicator confirms in Report Settings.
5.3 GET /api/v1/jobs/:jobId/sessions/:fileId/records
Per-point GPS trace records, cursor-paginated. Uses the same paginateWithCursor helper as the existing filesdata_post.
Query parameters:
| Param | Default | Description |
|---|---|---|
after |
— | Cursor (_id of last record received) — preferred by customer requirements |
startingAfter |
— | Cursor (_id of last record received) |
limit |
500 | Max records per page (hard cap: 2000) |
interval |
— | Return one record per N seconds of GPS time (e.g. 1, 5, 10) |
fm |
false |
Set fm=true to include Flight Master/AgDisp FM fields (see below). Off by default — only for customers with FM-enabled equipment. |
Field groups per record:
GPS Data: timeUtc, lat, lon, utmX, utmY, alt, grSpeed, heading, xTrack, lockedLine, hdop, satsInView, correctionId, waasId, sprayStat
Application Info: flowRateApplied, flowRateRequired, appRateRequired, appRateApplied*, swathWidth, boomPressure_psi, sprayOnLag_s†, sprayOffLag_s†, pulsesPerLiter†, rpm[]
MET: windSpeed_kt, windDir_deg, temp_c, humidity_pct
Compatibility aliases returned by implementation for existing consumers:
- None — aliases were removed; this is a new API with no existing consumers.
Output field definitions (records endpoint)
Response envelope fields:
| Field | Type | Required | Description |
|---|---|---|---|
data |
array | ✓ | Array of per-point records after filtering/thinning. |
has_more |
boolean | ✓ | True when additional pages exist. |
last_id |
string | null | — | Cursor value for the next page (null on last page). |
total_count |
number | undefined | — | Optional total count when pagination helper provides it. |
Per-record fields in data[]:
| Field | Type | Required | Description |
|---|---|---|---|
timeUtc |
string | null | — | GPS timestamp formatted as ISO 8601 UTC. |
gpsTime |
number | null | — | Raw GPS epoch seconds. |
lat |
number | null | — | Latitude (WGS84 decimal degrees). |
lon |
number | null | — | Longitude (WGS84 decimal degrees). |
utmX |
number | null | — | UTM X coordinate in meters. |
utmY |
number | null | — | UTM Y coordinate in meters. |
alt |
number | null | — | Altitude in meters. |
grSpeed |
number | null | — | Ground speed in m/s. |
heading |
number | null | — | Aircraft heading in degrees. |
xTrack |
number | null | — | Cross-track error in meters. |
lockedLine |
number | null | — | Locked line index from guidance data. |
hdop |
number | null | — | Horizontal dilution of precision. |
satsInView |
number | null | — | Decoded satellites in view. |
correctionId |
number | null | — | Decoded correction identifier. |
waasId |
number | null | — | Decoded WAAS identifier when available. |
sprayStat |
number | null | — | Spray state (3 is filtered out before response). |
flowRateApplied |
number | null | — | Applied flow rate (L/min). |
flowRateRequired |
number | null | — | Required flow rate (L/min). |
appRateRequired |
number | null | — | Required app rate from source data. |
appRateApplied |
number | null | — | Computed app rate applied, null on zero-division. |
swathWidth |
number | null | — | Swath width in meters. |
boomPressure_psi |
number | null | — | Boom pressure in PSI. |
sprayOnLag_s |
number | null | — | Session constant, repeated per record. |
sprayOffLag_s |
number | null | — | Session constant, repeated per record. |
pulsesPerLiter |
number | null | — | Session constant. |
rpm |
array | null | — | RPM array from raw data. |
windSpeed_kt |
number | null | — | Wind speed in knots (converted from m/s on output to match playback display). |
windDir_deg |
number | null | — | Wind direction in degrees. |
temp_c |
number | null | — | Temperature in Celsius. |
humidity_pct |
number | null | — | Relative humidity percentage. |
*
appRateAppliedis the only computed field:lminApp / (grSpeed × swath) × 10000. ReturnsnullwhengrSpeed = 0orswath = 0.
† Session constants fromAppFile.meta— same value repeated on every record for flat-file consumers.
FM fields (included only when ?fm=true is set):
| Field | Type | DB source | Description |
|---|---|---|---|
sprayHeight_m |
number | null | sprayHeight |
Target spray height in metres (AgDisp). |
driftX_m |
number | null | driftX |
Lateral drift offset X in metres (AgDisp). |
driftY_m |
number | null | driftY |
Lateral drift offset Y in metres (AgDisp). |
depositX_m |
number | null | depositX |
Deposit offset X in metres (AgDisp). |
depositY_m |
number | null | depositY |
Deposit offset Y in metres (AgDisp). |
radarAlt_m |
number | null | radarAlt |
Radar altimeter reading in metres. |
laserAlt_m |
number | null | raserAlt ¹ |
Laser altimeter reading in metres. |
¹ The source DB field is named
raserAlt(schema typo). The API exposes it aslaserAlt_mwith the correct name.
Record Decoding Transformation Pipeline:
graph LR
A[Raw AppDetail] --> B[Interval Thinning]
B --> C[Decode GPS Fields]
C --> D[Compute appRateApplied]
D --> E[Inject Session Meta]
E --> F[Filter sprayStat=3]
F --> G[Format ISO 8601 UTC]
G --> H[Return API Record]
style A fill:#fce4ec
style B fill:#f3e5f5
style C fill:#e8eaf6
style D fill:#f3e5f5
style E fill:#e0f2f1
style F fill:#fce4ec
style G fill:#fff9c4
style H fill:#c8e6c9
Decoding rules:
satsInView: rawsatsIn > 99→satsIn − 100correctionId: rawtslu > 100→tslu − 100waasId: only set whencalcodeFreqin range 20001–29999 →calcodeFreq − 20000sprayStat === 3(spray segment START marker) is filtered out — it anchors the start position for the next area/distance computation but carries no application measurement; only spray-on records (sprayStat !== 3 && sprayStat > 0) represent actual application dataappRateApplied=lminApp / (grSpeed × swath) × 10000; null when grSpeed or swath = 0
5.4 Public API Endpoint Architecture
graph LR
subgraph External[External Callers]
PBI[Power BI]
ARCGIS[ArcGIS]
DW[Data Warehouse]
end
subgraph PublicAPI[Public API /api/v1]
SESSIONS[GET /sessions]
RECORDS[GET /records]
AREAS[GET /areas]
TRIGEXP[POST /export]
POLLEXP[GET /export-status]
DOWNLOAD[GET /download]
end
subgraph Internal[Backend Models]
APP[(App)]
APPFILE[(AppFile)]
APPDETAIL[(AppDetail)]
EXPORTJOB[(ExportJob)]
end
PBI --> SESSIONS
ARCGIS --> AREAS
DW --> DOWNLOAD
SESSIONS --> APP
RECORDS --> APPDETAIL
AREAS --> APP
TRIGEXP --> EXPORTJOB
POLLEXP --> EXPORTJOB
DOWNLOAD --> EXPORTJOB
SESSIONS --> APPFILE
RECORDS --> APPFILE
style External fill:#e3f2fd
style PublicAPI fill:#f3e5f5
style Internal fill:#e8f5e9
5.5 GET /api/v1/jobs/:jobId/areas
Returns the planned spray-area polygons as a GeoJSON FeatureCollection.
Output field definitions (areas endpoint)
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | ✓ | Always FeatureCollection. |
jobId |
number | ✓ | Numeric job identifier from path. |
features |
array | ✓ | Array of polygon features from planned spray areas. |
Per-feature fields in features[]:
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | ✓ | Always Feature. |
properties.name |
string | null | — | Spray area name. |
properties.appRate |
number | null | — | Planned app rate for the area. |
properties.area_ha |
number | null | — | Planned area size in hectares. |
properties.type |
string | null | — | Area type metadata when present. |
geometry |
object | null | — | GeoJSON polygon geometry copied from job.sprayAreas. |
Only implement / expose once customer confirms this is needed for ArcGIS layer import (pending).
5.6 Async Export
Trigger:
POST /api/v1/jobs/:jobId/export
Body: { "format": "csv", "interval": 1, "units": "us" }
→ 202 { "exportId": "...", "status": "pending", "units": "us" }
Body parameters:
| Parameter | Required | Values | Default |
|---|---|---|---|
format |
Yes | 'csv', 'geojson' |
— |
interval |
No | seconds (e.g. 1, 5) |
null (all points) |
units |
No | 'metric' (ExportUnits.METRIC), 'us' (ExportUnits.US) |
'metric' |
fm |
No | true / false |
false — include Flight Master/AgDisp FM fields |
Poll:
GET /api/v1/exports/:exportId
→ { "status": "processing" } (repeat)
→ { "status": "ready", "downloadUrl": "/api/v1/exports/:id/download" }
→ { "status": "error", "errorMsg": "..." }
Download:
GET /api/v1/exports/:exportId/download
→ streams file with Content-Disposition: attachment
Output field definitions (export endpoints)
POST /api/v1/jobs/:jobId/export response fields (HTTP 202):
| Field | Type | Required | Description |
|---|---|---|---|
exportId |
string | ✓ | Export tracker identifier. |
status |
string | ✓ | Initial export status (pending). |
format |
string | ✓ | Selected format (csv or geojson). |
units |
string | ✓ | Selected units (metric or us). |
createdAt |
string | ✓ | Export tracker creation timestamp (ISO 8601 UTC). |
GET /api/v1/exports/:exportId response fields:
| Field | Type | Required | Description |
|---|---|---|---|
exportId |
string | ✓ | Export tracker identifier. |
status |
string | ✓ | pending, processing, ready, or error. |
format |
string | ✓ | Export format. |
units |
string | ✓ | Export units mode. |
createdAt |
string | ✓ | Creation timestamp. |
expiresAt |
string | null | — | Expiry timestamp for downloaded file cleanup. |
error |
string | null | — | Error message when generation fails. |
downloadUrl |
string | undefined | — | Present only when status is ready. |
GET /api/v1/exports/:exportId/download response:
| Item | Value |
|---|---|
| Body | Streamed file content (CSV or GeoJSON). |
Content-Type |
text/csv or application/geo+json. |
Content-Disposition |
Attachment filename with format extension. |
CSV structure: one row per AppDetail record. All raw trace fields plus job/session header columns (jobId, orderNumber, jobName, clientId, clientName, sessionId, fileName, pilotName) repeated on every row — no joins required for Power BI or data warehouse import. Column headers include unit suffix when units='us' (e.g. groundSpeed_mph vs groundSpeed_ms, temp_f vs temp_c).
US unit conversions (units='us'):
| Metric field | US field | Factor |
|---|---|---|
alt_m |
alt_ft |
× 3.28084 |
groundSpeed_ms |
groundSpeed_mph |
× 2.23694 |
crossTrackError_m |
crossTrackError_ft |
× 3.28084 |
swathWidth_m |
swathWidth_ft |
× 3.28084 |
flowRateApplied_Lmin |
flowRateApplied_galMin |
× 0.264172 |
flowRateRequired_Lmin |
flowRateRequired_galMin |
× 0.264172 |
appRateRequired_Lha |
appRateRequired_galAc |
× 0.10694 |
appRateApplied_Lha |
appRateApplied_galAc |
× 0.10694 |
windSpeed_kt |
windSpeed_mph |
× 1.15078 (kt → mph) |
temp_c |
temp_f |
× 9/5 + 32 |
boomPressure_psi |
boomPressure_psi |
already PSI — no conversion |
Implementation: Node.js Transform stream over AppDetail cursor → writes to env.TEMP_DIR. Keeps memory flat regardless of file size. interval thinning applied identically to the records endpoint.
5.7 Key Management Endpoints (Web UI, JWT-authenticated)
| Method | Path | Description |
|---|---|---|
GET |
/api/keys |
List active keys for the signed-in applicator |
POST |
/api/keys |
Create a key — returns full plain key once in the response |
DELETE |
/api/keys/:keyId |
Revoke a key (sets active: false) |
Key management body (POST /api/keys):
{ "label": "Power BI Prod", "service": "data_export" }
service is optional and defaults to 'data_export'. Valid values are defined in ApiKeyServices in helpers/constants.js.
Admin users may append ?ownerId=<ObjectId> or include ownerId in the POST body to manage keys for another account.
6. avgSpraySpeed — Storage Strategy
Rather than computing average spray speed on demand (which would require scanning all AppDetail records for every session summary request), it is computed once at import time and stored in App.avgSpraySpeed.
Accumulation logic in job_worker.js:
// Per GPS point during file parsing (in importDataFiles, per-file pass):
// sprayStat=3 is the segment START marker — saves prevPos for next area calc but is
// not an actual spray record; exclude it from speed averaging.
if (record.sprayStat !== 3 && record.sprayStat > 0 && utils.isNumber(record.grSpeed)) {
totalSpeedAcc += record.grSpeed;
spraySpeedCount++;
}
// At end of file:
importInfo.avgSpraySpeed = spraySpeedCount > 0 ? totalSpeedAcc / spraySpeedCount : null; // m/s
One-time back-fill: scripts/migrate_avg_spray_speed.js — iterates existing App docs via cursor, re-scans their AppDetail records, bulk-writes the value. Safe to run on production (cursor-based, low memory, progress logging every 100 docs).
7. Frontend Design
7.1 Job List Filter Enhancement (Step 3 — pending)
File: src/app/job/job-list/job-list.component.ts
Add an orderNumber text filter control to the existing filter bar alongside client, status, and date pickers. Wire into the existing Job.Fetch() NgRx action that calls jobService.loadJobs(). Minor backend check: ensure searchJobs_post / getJobs_get accepts orderNumber as a partial-match filter.
7.2 API Key Management UI (Step 8 — pending)
New lazy-loaded feature module following the same NgRx pattern as PartnerListComponent / ClientListComponent.
Structure:
src/app/settings/api-keys/
api-keys.module.ts
api-keys-routing.module.ts
api-keys-list/
api-keys-list.component.ts
api-keys-list.component.html
store/
api-key.actions.ts
api-key.reducer.ts
api-key.effects.ts
services/
api-key.service.ts
UX flow:
- PrimeNG
p-tablelisting keys — columns: Label, Prefix, Created, Last Used, Status - "Generate Key" button → calls
POST /api/keys→ shows full key in ap-dialogwith copy-to-clipboard — key masked after dialog is closed, never retrievable again - "Revoke" button per row →
p-confirmDialog→ callsDELETE /api/keys/:id - Admin view: additional applicator selector (
p-dropdown) to manage keys on behalf of any account
8. Implementation Status
| Step | Feature | Status | Notes |
|---|---|---|---|
| 1 | App.avgSpraySpeed — model field + import worker + migration script |
✅ Done | model/application.js, workers/job_worker.js, scripts/migrate_avg_spray_speed.js |
| 2 | ApiKey model + checkApiKey middleware + /api/keys CRUD |
✅ Done | model/api_key.js, middlewares/app_validator.js, routes/api_keys.js, controllers/api_key.js. ApiKeyServices frozen constant controls valid service values. |
| 3 | Job List UI filter enhancements | ⬜ Pending | Frontend only — job-list.component |
| 4 | GET /api/v1/jobs/:id/sessions — session summary |
✅ Done | controllers/api_pub.js getSessions |
| 5 | GET /api/v1/jobs/:id/sessions/:fid/records — raw trace |
✅ Done | controllers/api_pub.js getSessionRecords |
| 6 | GET /api/v1/jobs/:id/areas — spray-area GeoJSON |
✅ Done | controllers/api_pub.js getAreas — awaiting customer confirmation to expose |
| 7 | Async export (POST /export, GET /exports/:id, download) |
✅ Done | model/export_job.js, controllers/api_export.js. ExportUnits frozen constant controls valid units values; US unit conversions applied at output time. |
| 8 | API Key management UI (Angular) | ⬜ Pending | New settings/api-keys feature module |
| 9 | Sandbox seeding script | ⬜ Pending | scripts/seed_sandbox.js |
| — | Tests | ⬜ Pending | checkApiKey unit tests, session summary integration tests |
9. Key Design Decisions
| Decision | Rationale |
|---|---|
Separate checkApiKey middleware (not extending checkUser) |
Zero risk to existing JWT-protected routes; req.uid set identically so all ownership filters work unchanged |
prefix stored clear-text in ApiKey |
O(1) candidate row lookup before expensive bcrypt.compare; prefix alone is not usable as a key |
ApiKeyServices frozen constant for service enum |
Single source of truth in helpers/constants.js; adding a new service type requires editing the constant only — model and controller stay in sync via Object.values() |
ExportUnits frozen constant for units enum |
Same principle — consistent with project convention for all enumeric text constants |
avgSpraySpeed stored at import, not computed on demand |
Session summary endpoint must never touch AppDetail (billion-scale collection); O(1) read from App model |
Cursor pagination on AppDetail._id |
Consistent with existing filesdata_post pattern; no skip-based offset that degrades on large collections |
interval thinning on both records endpoint and export |
Consistent behaviour; reduces Power BI payload for overview queries; daily batch export at 17:00 can use interval=1 to shrink CSV size significantly |
reportConfirmed boolean + always-populated fallback |
Consumer's data warehouse always has a usable record; can upsert when field flips to true |
Async export with TTL (ExportJob.expiresAt + MongoDB TTL index) |
Files self-clean after 24 hours; no manual housekeeping job needed |
| CSV columns include job/session header repeated per row | Direct Power BI / warehouse import without requiring a separate join step |
| Unit conversion at output time, not at storage | Raw data stored in metric throughout; conversion applied in recordToRow() with unit-labelled column headers so output is self-documenting |
10. Constraints & Notes
- All API responses use metric units by default (ha, m/s, L/min, L/ha, Kg/ha, °C, metres). Callers may request US customary output via
units: 'us'on the export endpoint — see Section 5.6. - All dates/times are ISO 8601 UTC strings.
- Coordinates are WGS84 decimal degrees (EPSG:4326) — numerically equivalent to SIRGAS 2000 (EPSG:4674) for Brazil.
AppDetail.sprayStat === 3is a spray segment START marker — it records the anchor position (UTM X/Y, swath, line number) that the worker uses to compute the area of the next spray segment. It carries no application measurement and is filtered from all public API responses. Spray-on application records aresprayStat > 0 && sprayStat !== 3.AppDetail.raserAlt(typo in source schema) is exposed aslaserAlt_min the API.rpm[]array semantics differ between liquid and dry material types — consumers must usematTypefrom the session summary to interpret correctly.- Pending: customer confirmation on whether
GET /api/v1/jobs/:id/areas(spray-area GeoJSON) is required for their ArcGIS workflow — endpoint is implemented but not yet scheduled for release.