agmission/server/routes/api_pub.js

411 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* Public Data Export API routes — mounted at /api/v1/
* All routes authenticated via checkApiKey (X-API-Key header).
*
* ─── Integration guide ───────────────────────────────────────────────────────
*
* Session summary:
* GET /api/v1/jobs/:jobId/sessions
* → Returns one record per uploaded file for the job.
* reportConfirmed: false when applicator has not yet confirmed values in Report Settings.
* Re-fetch when your data warehouse detects this field changed.
*
* Raw GPS trace (paginated):
* GET /api/v1/jobs/:jobId/sessions/:fileId/records
* Query: startingAfter=<cursor>, limit=<n>, interval=<seconds>
* → Use interval=1 or interval=5 for lighter Power BI queries.
* → Use the /export endpoint instead for full bulk loads.
*
* Spray-area polygons:
* GET /api/v1/jobs/:jobId/areas
* → GeoJSON FeatureCollection of planned spray-area polygons.
*
* Async bulk export:
* POST /api/v1/jobs/:jobId/export body: { format: 'csv'|'json', interval?: number }
* GET /api/v1/exports/:exportId poll for { status, downloadUrl }
* GET /api/v1/exports/:exportId stream file
*
* ─────────────────────────────────────────────────────────────────────────────
*
* FE integration notes:
* - The key management UI (create/list/revoke keys) lives at /api/keys — see routes/api_keys.js.
* - The API key is supplied as the X-API-Key request header, NOT Authorization Bearer.
* - For Power BI: use paginated records endpoint with startingAfter cursor for incremental refresh.
* - For ArcGIS / daily batch: use the export endpoint — POST once, poll, then download CSV/JSON.
*/
module.exports = function (app) {
const router = require('express').Router();
const rateLimit = require('express-rate-limit');
const { checkApiKey } = require('../middlewares/app_validator');
const pubCtl = require('../controllers/api_pub');
const exportCtl = require('../controllers/api_export');
const env = require('../helpers/env');
// Apply API key auth to all /api/v1/ routes
router.use(checkApiKey);
/**
* Per-account rate limiter — applied after checkApiKey so req.uid is available.
*
* ── Configuration ──────────────────────────────────────────────────────
* Keyed on account ID (not IP) to prevent one API key from flooding the export pipeline.
*
* Environment variables (see helpers/env.js):
* EXPORT_RATE_LIMIT_MAX: 20 — Max export triggers per account per window
* EXPORT_RATE_LIMIT_WINDOW_MINS: 60 — Time window in minutes
*
* ── Behavior ────────────────────────────────────────────────────────────
* Default: 20 exports per 60 minutes = 1 export every 3 minutes
*
* When exceeded:
* HTTP 429 Too Many Requests
* Response headers: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After
*
* ── Deduplication ───────────────────────────────────────────────────────
* Rate limit is NOT consumed if the request is deduplicated:
* - Existing ready export (same job/format/units) → return cached
* - Existing in-progress export (within EXPORT_DEDUP_MINS) → return existing
*
* ── Documentation ───────────────────────────────────────────────────────
* See docs/DATA_EXPORT_API_RATE_LIMITING.md for:
* - Detailed examples and scenarios
* - Best practices for batch workflows
* - Handling 429 responses
* - Integration guide for customers
*
* See docs/DATA_EXPORT_CUSTOMER_INTEGRATION_GUIDE.md for:
* - Full API documentation (all 6 endpoints)
* - Use cases and code examples
* - Error handling
*/
const exportAccountLimiter = rateLimit({
windowMs: env.EXPORT_RATE_LIMIT_WINDOW_MINS * 60 * 1000,
max: env.EXPORT_RATE_LIMIT_MAX,
keyGenerator: req => String(req.uid),
skipFailedRequests: true,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Export rate limit exceeded. Please wait before requesting another export.' }
});
// ── Session summary ──────────────────────────────────────────────────────
/**
* @api {get} /api/v1/jobs/:jobId/sessions Get Session Summary
* @apiVersion 1.0.0
* @apiName GetSessions
* @apiGroup Sessions
* @apiDescription Returns aggregated spray application data (coverage, timing, pilot, aircraft)
* from one or more flight files. Each session represents one uploaded log file.
*
* @apiParam {Number} jobId Job ID
*
* @apiHeader {String} X-API-Key API key (e.g., ak_test_xxx)
*
* @apiSuccess (200) {Number} jobId Job identifier
* @apiSuccess (200) {String} [clientId] Client account ObjectId
* @apiSuccess (200) {String} [clientName] Client account name
* @apiSuccess (200) {Boolean} reportConfirmed True if applicator confirmed values in Report Settings
* @apiSuccess (200) {Number} mappedArea_ha Job mapped area in hectares (`rptOp.areaSize` fallback `ttSprArea`)
* @apiSuccess (200) {Number} areaSize_ha Planned spray area (hectares)
* @apiSuccess (200) {Number} coverage_ha Actual coverage (hectares)
* @apiSuccess (200) {Number} overSprayed_pct Over-spray percentage `(coverage - areaSize) / areaSize * 100` (2 dp)
* @apiSuccess (200) {Number} appRate Application rate (material per area)
* @apiSuccess (200) {String} appRateUnit Rate unit string (e.g., 'lit/ha', 'oz/ac')
* @apiSuccess (200) {Number} [appRateConfirmed] Confirmed app rate only; null when not confirmed
* @apiSuccess (200) {String} volumeUnit Material unit string (`lit` or `gal`) derived from `job.measureUnit`
* @apiSuccess (200) {Number} sprayVolume Planned/estimated spray volume (`coverage_ha × appRate`) converted by `job.measureUnit`
* @apiSuccess (200) {Boolean} useConfirmedVolume True when confirmed actual volume override is active
* @apiSuccess (200) {Number} [actualSprayVolume] Actual spray volume calculated from applications (`SUM(App.totalSprayMat)` normalized and converted)
* @apiSuccess (200) {Number} [confirmedActualVolume] Confirmed actual spray volume from report settings (`rptOp.actualVol`) converted by `job.measureUnit`
* @apiSuccess (200) {Number} [effectiveVolume] Authoritative volume (`confirmedActualVolume` when `useConfirmedVolume=true`, otherwise `actualSprayVolume`)
* @apiSuccess (200) {Boolean} useCustomWeather True when custom weather was entered on the job
* @apiSuccess (200) {Object} [weather] Custom weather block when present
* @apiSuccess (200) {Number} [weather.windSpeed_kt] Wind speed in knots
* @apiSuccess (200) {String} [weather.windDir] Wind direction value stored on the job
* @apiSuccess (200) {Number} [weather.temp_c] Temperature in Celsius
* @apiSuccess (200) {Number} [weather.humidity_pct] Humidity percent
* @apiSuccess (200) {Object[]} data Array of session records (one per file)
* @apiSuccess (200) {String} data.sessionId Session/file ID
* @apiSuccess (200) {String} data.fileName Log file name
* @apiSuccess (200) {String} data.startDateTime ISO 8601 start time
* @apiSuccess (200) {String} data.endDateTime ISO 8601 end time
* @apiSuccess (200) {Number} data.totalFlightTime_s Total flight time (seconds)
* @apiSuccess (200) {Number} data.totalSprayTime_s Total spray time (seconds)
* @apiSuccess (200) {Number} data.totalTurnTime_s Total turn time (seconds)
* @apiSuccess (200) {Number} data.totalSprayed_ha Area sprayed (hectares)
* @apiSuccess (200) {Number} data.totalSprayMat Total material sprayed
* @apiSuccess (200) {String} data.totalSprayMatUnit Material unit (e.g., 'lit', 'gal', 'kg')
* @apiSuccess (200) {Number} data.avgSpraySpeed_ms Average spray speed (m/s)
* @apiSuccess (200) {String} [data.sprayZoneName] Spray zone/area name from file metadata
* @apiSuccess (200) {Number} [data.sprayZoneArea_ha] Spray zone area in hectares from file metadata
* @apiSuccess (200) {Number} data.appRate Application rate
* @apiSuccess (200) {String} data.appRateUnit Rate unit (e.g., 'lit/ha')
* @apiSuccess (200) {String} [data.flowController] Flow controller display name
* @apiSuccess (200) {Number} [data.sprayOnLag_s] Spray-on lag in seconds
* @apiSuccess (200) {Number} [data.sprayOffLag_s] Spray-off lag in seconds
* @apiSuccess (200) {Number} [data.pulsesPerLiter] Pulses per liter from file metadata
* @apiSuccess (200) {Object[]} data.files File list for the session
* @apiSuccess (200) {String} data.files.fileId File ObjectId
* @apiSuccess (200) {String} [data.files.name] File name
* @apiSuccess (200) {String} [assignedPilotId] Assigned pilot ObjectId (from job record)
* @apiSuccess (200) {String} [assignedPilotName] Assigned pilot name (from job record)
* @apiSuccess (200) {String} [assignedAircraftId] Assigned aircraft ObjectId from latest live JobAssign (null when not live-assigned)
* @apiSuccess (200) {String} [assignedAircraftName] Assigned aircraft name from latest live JobAssign (null when not live-assigned)
* @apiSuccess (200) {String} [assignedAircraftTailNumber] Assigned aircraft tail number from latest live JobAssign (null when not live-assigned)
* @apiSuccess (200) {String} [planAircraftName] Planned aircraft name (from job record)
* @apiSuccess (200) {String} [planAircraftTailNumber] Planned aircraft tail number (from job record)
* @apiSuccess (200) {String} [assignedDate] Latest job assignment timestamp (ISO 8601 UTC)
*
* @apiError (401) {Object} error Not authorized (missing/invalid X-API-Key)
* @apiError (404) {Object} error Job not found
*
* @apiExample {curl} Example Usage:
* curl -X GET https://api.agmission.com/api/v1/jobs/12345/sessions \
* -H "X-API-Key: ak_test_..."
*
* @apiSeeAlso GET /api/v1/jobs/:jobId/sessions/:fileId/records, GET /api/v1/jobs/:jobId/areas, POST /api/v1/jobs/:jobId/export
*/
router.get('/jobs/:jobId/sessions', pubCtl.getSessions);
// ── Raw GPS trace records ────────────────────────────────────────────────
/**
* @api {get} /api/v1/jobs/:jobId/sessions/:fileId/records Get Session Records (Paginated)
* @apiVersion 1.0.0
* @apiName GetSessionRecords
* @apiGroup Sessions
* @apiDescription Returns raw GPS trace points with cursor-based pagination.
* Use `interval` parameter for GPS thinning (e.g., every 5 seconds).
* Recommended for incremental Power BI refresh and lightweight queries.
*
* @apiParam {Number} jobId Job ID
* @apiParam {String} fileId Session/file ID
* @apiParam {String} [after] Alias for `startingAfter`
* @apiParam {String} [startingAfter] Cursor for next page
* @apiParam {Number} [limit=500] Records per page (max configured by PUBLIC_API_RECORDS_MAX_LIMIT)
* @apiParam {Number} [interval] GPS thinning interval (seconds, float). Use `interval=0` (or omit) for no thinning.
* @apiParam {Boolean} [fm=false] Include Flight Master / AgDisp fields
*
* @apiHeader {String} X-API-Key API key
*
* @apiSuccess (200) {Object[]} data Array of GPS records
* @apiSuccess (200) {String} data.timeUtc ISO 8601 timestamp
* @apiSuccess (200) {Number} data.gpsTime Raw GPS time value from AppDetail
* @apiSuccess (200) {Number} data.lat Latitude (decimal degrees)
* @apiSuccess (200) {Number} data.lon Longitude (decimal degrees)
* @apiSuccess (200) {Number} data.utmX UTM X coordinate
* @apiSuccess (200) {Number} data.utmY UTM Y coordinate
* @apiSuccess (200) {Number} data.alt Altitude (meters)
* @apiSuccess (200) {Number} data.grSpeed Ground speed (m/s)
* @apiSuccess (200) {Number} data.heading Heading (degrees)
* @apiSuccess (200) {Number} data.xTrack Cross-track error
* @apiSuccess (200) {Number} data.lockedLine Locked guidance line number
* @apiSuccess (200) {Number} data.hdop HDOP value
* @apiSuccess (200) {Number} data.satsIn Raw satellite/inside-area value.
* AgNav native NT binary encoding: value is satellite count, plus 100 when inside spray area.
* If value < 100: outside area, number of satellites in view.
* If value >= 100: inside area, satellites = value - 100.
* @apiSuccess (200) {Number} data.tslu Raw time since last GPS differential correction update (seconds)
* @apiSuccess (200) {Number} data.calcodeFreq Raw calibration/frequency field.
* 30000-60000 indicates frequency/RPM => true RPM = calcodeFreq - 30000.
* Also used for spray offset in decimeter: <20000 positive offset; >60000 negative offset (stored as 65536 - abs(offset)).
* @apiSuccess (200) {Number} data.sprayStat Spray state value from source data (for example 0=off, 1/2=application, 3=segment-start marker)
* @apiSuccess (200) {Number} data.flowRateApplied Flow rate applied (L/min)
* @apiSuccess (200) {Number} data.flowRateRequired Flow rate required (L/min)
* @apiSuccess (200) {Number} data.appRateRequired Required application rate
* @apiSuccess (200) {Number} data.appRateApplied Application rate applied (L/ha)
* @apiSuccess (200) {Number} data.swathWidth Swath width (meters)
* @apiSuccess (200) {Number} data.boomPressure_psi Boom pressure (PSI)
* @apiSuccess (200) {String} [data.flowController] Flow controller display name
* @apiSuccess (200) {Number} [data.sprayOnLag_s] Spray-on lag in seconds
* @apiSuccess (200) {Number} [data.sprayOffLag_s] Spray-off lag in seconds
* @apiSuccess (200) {Number} [data.pulsesPerLiter] Pulses per liter from session metadata
* @apiSuccess (200) {Array} [data.rpm] RPM array/value from source data
* @apiSuccess (200) {Number} data.windSpeed_kt Wind speed in knots
* @apiSuccess (200) {Number} data.windDir_deg Wind direction (0-360°)
* @apiSuccess (200) {Number} data.temp_c Temperature (°C)
* @apiSuccess (200) {Number} data.humidity_pct Humidity (%)
* @apiSuccess (200) {Number} [data.sprayHeight_m] Flight Master spray height (when `fm=true`)
* @apiSuccess (200) {Number} [data.driftX_m] Flight Master drift X offset (when `fm=true`)
* @apiSuccess (200) {Number} [data.driftY_m] Flight Master drift Y offset (when `fm=true`)
* @apiSuccess (200) {Number} [data.depositX_m] Flight Master deposit X offset (when `fm=true`)
* @apiSuccess (200) {Number} [data.depositY_m] Flight Master deposit Y offset (when `fm=true`)
* @apiSuccess (200) {Number} [data.radarAlt_m] Radar altitude in meters (when `fm=true`)
* @apiSuccess (200) {Number} [data.laserAlt_m] Laser altitude in meters (when `fm=true`)
* @apiSuccess (200) {Boolean} hasMore True if more records available
* @apiSuccess (200) {String} [nextCursor] Cursor for next page
*
* @apiError (401) {Object} error Not authorized
* @apiError (404) {Object} error Session/file not found
*
* @apiExample {curl} Fetch 500 records, every 5 seconds:
* curl "https://api.agmission.com/api/v1/jobs/12345/sessions/507f1f77.../records?limit=500&interval=5" \
* -H "X-API-Key: ak_test_..."
*
* @apiExample {curl} Fetch next page:
* curl "https://api.agmission.com/api/v1/jobs/12345/sessions/507f1f77.../records?startingAfter=507f191e810c19729de8605f" \
* -H "X-API-Key: ak_test_..."
*/
router.get('/jobs/:jobId/sessions/:fileId/records', pubCtl.getSessionRecords);
// ── Spray-area GeoJSON polygons ──────────────────────────────────────────
/**
* @api {get} /api/v1/jobs/:jobId/areas Get Spray Areas (GeoJSON)
* @apiVersion 1.0.0
* @apiName GetAreas
* @apiGroup Areas
* @apiDescription Returns GeoJSON FeatureCollection of planned spray zones and exclusion boundaries.
* Features include spray areas (`type: "area"`) and no-spray zones (`type: "xcl"`).
*
* @apiParam {Number} jobId Job ID
*
* @apiHeader {String} X-API-Key API key
*
* @apiSuccess (200) {String} type GeoJSON type ("FeatureCollection")
* @apiSuccess (200) {Number} jobId Associated job ID
* @apiSuccess (200) {Object[]} features Array of GeoJSON features
* @apiSuccess (200) {String} features.type GeoJSON type ("Feature")
* @apiSuccess (200) {Object} features.properties Feature properties
* @apiSuccess (200) {String} features.properties.name Feature name
* @apiSuccess (200) {String} features.properties.type Feature type ("area" or "xcl")
* @apiSuccess (200) {Number} [features.properties.area_ha] Area in hectares (for type="area")
* @apiSuccess (200) {Number} [features.properties.appRate] Application rate (for type="area")
* @apiSuccess (200) {String} [features.properties.appRateUnit] Rate unit (for type="area", e.g., 'lit/ha')
* @apiSuccess (200) {Number} [features.properties.appRateUnitCode] Raw application-rate unit code
* @apiSuccess (200) {Object} features.geometry GeoJSON geometry (Polygon)
* @apiSuccess (200) {String} features.geometry.type Geometry type ("Polygon")
* @apiSuccess (200) {Number[][][]} features.geometry.coordinates Polygon coordinates
*
* @apiError (401) {Object} error Not authorized
* @apiError (404) {Object} error Job not found
*
* @apiExample {curl} Example Usage:
* curl -X GET https://api.agmission.com/api/v1/jobs/12345/areas \
* -H "X-API-Key: ak_test_..."
*
* @apiSeeAlso GET /api/v1/jobs/:jobId/sessions
*/
router.get('/jobs/:jobId/areas', pubCtl.getAreas);
// ── Async export ─────────────────────────────────────────────────────────
/**
* @api {post} /api/v1/jobs/:jobId/export Trigger Async Export
* @apiVersion 1.0.0
* @apiName TriggerExport
* @apiGroup Exports
* @apiDescription Initiates async generation of a bulk CSV or JSON export.
* Returns immediately with exportId; use GET /exports/:exportId to poll for status.
*
* Request deduplication: Identical requests within 5 minutes reuse existing export (no rate limit consumed).
* Per-account rate limit: 20 exports per 60 minutes (configurable).
*
* @apiParam {Number} jobId Job ID
*
* @apiHeader {String} X-API-Key API key
* @apiHeader {String} Content-Type application/json
*
* @apiBody {String} format Export format: "csv" or "json"
* @apiBody {String} [units="metric"] Unit system: "metric" (default) or "us"
* @apiBody {Number} [interval] GPS thinning interval in seconds (float, optional)
*
* @apiSuccess (202) {String} exportId Export job ID
* @apiSuccess (202) {String} status Export status ("pending")
* @apiSuccess (202) {String} format Export format
* @apiSuccess (202) {String} units Unit system
* @apiSuccess (202) {String} createdAt ISO 8601 creation timestamp
*
* @apiSuccess (200) {String} exportId Export job ID (reused from cache)
* @apiSuccess (200) {String} status Export status ("ready" or "pending")
* @apiSuccess (200) {Boolean} reused=true Indicates request was deduplicated
* @apiSuccess (200) {String} [downloadUrl] Download URL (if status="ready")
*
* @apiError (401) {Object} error Not authorized
* @apiError (404) {Object} error Job not found
* @apiError (409) {Object} error Invalid parameters
* @apiError (429) {Object} error Rate limit exceeded
*
* @apiHeader {Number} RateLimit-Limit Maximum requests per account per window
* @apiHeader {Number} RateLimit-Remaining Requests remaining in current window
* @apiHeader {Number} RateLimit-Reset Unix timestamp of window reset
* @apiHeader {Number} Retry-After Seconds to wait before retrying (on 429 only)
*
* @apiExample {curl} Trigger CSV export:
* curl -X POST https://api.agmission.com/api/v1/jobs/12345/export \
* -H "X-API-Key: ak_test_..." \
* -H "Content-Type: application/json" \
* -d '{"format":"csv","units":"metric"}'
*
* @apiSeeAlso GET /api/v1/exports/:exportId, GET /api/v1/exports/:exportId/download
*/
router.post('/jobs/:jobId/export', exportAccountLimiter, exportCtl.triggerExport);
/**
* @api {get} /api/v1/exports/:exportId Get Export Status
* @apiVersion 1.0.0
* @apiName GetExportStatus
* @apiGroup Exports
* @apiDescription Polls the status of an async export job.
* Keep polling until status is "ready", then download the file.
*
* @apiParam {String} exportId Export job ID (returned by POST /export)
*
* @apiHeader {String} X-API-Key API key
*
* @apiSuccess (200) {String} exportId Export job ID
* @apiSuccess (200) {String} status Export status: "pending", "processing", "ready", or "error"
* @apiSuccess (200) {String} format Export format ("csv" or "json")
* @apiSuccess (200) {String} units Unit system
* @apiSuccess (200) {String} createdAt ISO 8601 creation timestamp
* @apiSuccess (200) {String} [expiresAt] ISO 8601 expiry timestamp (file available until this time)
* @apiSuccess (200) {String} [downloadUrl] Download endpoint URL (when status="ready")
* @apiSuccess (200) {String} [error] Error message (when status="error")
*
* @apiError (401) {Object} error Not authorized
* @apiError (404) {Object} error Export not found
*
* @apiExample {curl} Poll export status:
* curl -X GET https://api.agmission.com/api/v1/exports/66f4a8c1.../status \
* -H "X-API-Key: ak_test_..."
*
* @apiSeeAlso POST /api/v1/jobs/:jobId/export, GET /api/v1/exports/:exportId/download
*/
router.get('/exports/:exportId', exportCtl.getExportStatus);
/**
* @api {get} /api/v1/exports/:exportId/download Download Export File
* @apiVersion 1.0.0
* @apiName DownloadExport
* @apiGroup Exports
* @apiDescription Streams the ready export file (CSV or JSON).
* Must call GET /exports/:exportId first and wait for status="ready".
*
* Files remain available for download until expiresAt (default 24 hours after ready).
* Can be downloaded multiple times before expiry.
*
* @apiParam {String} exportId Export job ID
*
* @apiHeader {String} X-API-Key API key
*
* @apiSuccess (200) {Binary} file File stream (CSV or JSON)
* @apiSuccessExample {curl} Response Headers:
* HTTP/1.1 200 OK
* Content-Type: text/csv
* Content-Disposition: attachment; filename="export_job12345_66f4a8c1.csv"
* Content-Length: 1048576
*
* @apiError (401) {Object} error Not authorized
* @apiError (404) {Object} error Export not found or expired
*
* @apiExample {curl} Download export:
* curl -X GET https://api.agmission.com/api/v1/exports/66f4a8c1.../download \
* -H "X-API-Key: ak_test_..." \
* -o export_job12345.csv
*
* @apiSeeAlso GET /api/v1/exports/:exportId
*/
router.get('/exports/:exportId/download', exportCtl.downloadExport);
app.use('/api/v1', router);
};