agmission/Development/server/controllers/api_pub.js
Devin Major df31b2080d
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 42s
-(#3013) Data Export - Implement Data Export API BE (Cont.)
+ 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
2026-04-24 09:05:55 -04:00

481 lines
18 KiB
JavaScript
Raw 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 controller — /api/v1/ routes.
* All functions are authenticated via checkApiKey (X-API-Key header).
* req.uid is set identically to checkUser, so all ownership scoping is automatic.
*
* Endpoints implemented here:
* GET /api/v1/jobs/:jobId/sessions → session summary list
* GET /api/v1/jobs/:jobId/sessions/:fileId/records → raw GPS trace (paginated)
* GET /api/v1/jobs/:jobId/areas → GeoJSON spray-area polygons
*/
const ObjectId = require('mongodb').ObjectId;
const moment = require('moment');
const { Job, App, AppFile, AppDetail, JobAssign, Vehicle, Pilot } = require('../model');
const { paginateWithCursor, validateCursorParams } = require('../helpers/cursor_pagination');
const { AppParamError, AppAuthError } = require('../helpers/app_error');
const { Errors, HttpStatus, ExportAreaTypes } = require('../helpers/constants');
const utils = require('../helpers/utils');
// ─── helpers ─────────────────────────────────────────────────────────────────
/** Parse a positive-float interval value from a query/body param. Returns null if absent or invalid. */
function parseInterval(raw) {
if (raw == null || raw === '') return null;
const v = parseFloat(raw);
return isFinite(v) && v > 0 ? v : null;
}
/** Decode satsInView from raw satsIn field (>99 means corrected) */
function decodeSatsIn(raw) {
if (!utils.isNumber(raw)) return null;
return raw > 99 ? raw - 100 : raw;
}
/** Decode correctionId and waasId from tslu and calcodeFreq */
function decodeCorrectionFields(tslu, calcodeFreq) {
const correctionId = utils.isNumber(tslu) ? (tslu > 100 ? tslu - 100 : tslu) : null;
let waasId = null;
if (utils.isNumber(calcodeFreq) && calcodeFreq >= 20001 && calcodeFreq <= 29999) {
waasId = calcodeFreq - 20000;
}
return { correctionId, waasId };
}
/**
* Compute appRateApplied from raw fields.
* Formula: lminApp / (grSpeed_m_s × swath_m) × 10000
* Returns null on zero-division to avoid Infinity.
*/
function computeAppRateApplied(lminApp, grSpeed, swath) {
if (!utils.isNumber(lminApp) || !utils.isNumber(grSpeed) || !utils.isNumber(swath)) return null;
if (grSpeed === 0 || swath === 0) return null;
return lminApp / (grSpeed * swath) * 10000;
}
/**
* Convert AppDetail.gpsTime to an ISO UTC timestamp.
* Supports both epoch-seconds and legacy seconds-of-day values.
*/
function toRecordTimeUtc(gpsTime, appStartDateTime) {
if (!utils.isNumber(gpsTime)) return null;
// Epoch seconds (>= year 2000-01-01 UTC) can be converted directly.
if (gpsTime >= 946684800) {
return moment.unix(gpsTime).utc().toISOString();
}
// Legacy format: seconds-of-day, anchor to app start date when available.
const base = moment.utc(appStartDateTime, [moment.ISO_8601, 'YYYYMMDDTHHmmss'], true);
if (base.isValid()) {
const dayOffset = Math.floor(gpsTime / 86400);
const secOfDay = ((gpsTime % 86400) + 86400) % 86400;
return base.clone().startOf('day').add(dayOffset, 'days').add(secOfDay, 'seconds').toISOString();
}
// Fallback for malformed app start datetime.
return moment.unix(gpsTime).utc().toISOString();
}
/**
* Map a raw AppDetail document to the public API record shape.
* sessionMeta contains session-constant fields from AppFile.meta injected once per page.
*/
/**
* Normalise flow controller name to match playback display:
* null/empty/case-insensitive 'none' values → 'No FC'.
*/
function normaliseFlowController(fcName) {
return (fcName && !/none/i.test(fcName)) ? fcName : 'No FC';
}
function getLaserAlt(detail) {
return detail?.laserAlt ?? detail?.raserAlt ?? null;
}
function mapDetailRecord(d, sessionMeta, appStartDateTime, includeFm = false) {
const { correctionId, waasId } = decodeCorrectionFields(d.tslu, d.calcodeFreq);
const appRateApplied = computeAppRateApplied(d.lminApp, d.grSpeed, d.swath);
const pulsesPerLiter = sessionMeta?.pulsesPerLit ?? null;
const rec = {
// GPS Data
timeUtc: toRecordTimeUtc(d.gpsTime, appStartDateTime),
gpsTime: d.gpsTime,
lat: d.lat,
lon: d.lon,
utmX: d.utmX,
utmY: d.utmY,
alt: d.alt,
grSpeed: d.grSpeed,
heading: d.head,
xTrack: d.xTrack,
lockedLine: d.llnum,
hdop: d.stdHdop,
satsInView: decodeSatsIn(d.satsIn),
correctionId,
waasId,
sprayStat: d.sprayStat,
// Application Info
flowRateApplied: d.lminApp,
flowRateRequired: d.lminReq,
appRateRequired: d.lhaReq,
appRateApplied,
swathWidth: d.swath,
boomPressure_psi: d.psi,
// Session-constant fields from AppFile.meta (repeated per record for flat-file consumers)
flowController: normaliseFlowController(sessionMeta?.fcName),
sprayOnLag_s: sessionMeta?.sprOnLag ?? null,
sprayOffLag_s: sessionMeta?.sprOffLag ?? null,
pulsesPerLiter,
rpm: d.rpm,
// MET — wind speed in knots to match playback display; AppDetail stores m/s internally
windSpeed_kt: utils.isNumber(d.windSpd) ? +(d.windSpd * 1.94384).toFixed(4) : null,
windDir_deg: d.windDir,
temp_c: d.temp,
humidity_pct: d.humid
};
if (includeFm) {
// Flight Master / AgDisp fields — only included when fm=true is requested.
// raserAlt is a typo in the AppDetail schema; exposed here as laserAlt_m.
rec.sprayHeight_m = d.sprayHeight ?? null;
rec.driftX_m = d.driftX ?? null;
rec.driftY_m = d.driftY ?? null;
rec.depositX_m = d.depositX ?? null;
rec.depositY_m = d.depositY ?? null;
rec.radarAlt_m = d.radarAlt ?? null;
rec.laserAlt_m = getLaserAlt(d);
}
return rec;
}
/**
* Build the confirmed-values block for a session, with fallback to raw aggregates.
* @param {Object} job - lean Job document (needs rptOp, useCustWI, weatherInfo, sprayAreas)
* @param {Object[]} apps - lean App[] for this job
*/
function buildConfirmedValues(job, apps, firstMetaAppRate = null) {
const rptOp = job.rptOp;
const reportConfirmed = !!(rptOp && rptOp.coverage != null);
// Area size: confirmed or sum of sprayArea polygons
const areaSize_ha = reportConfirmed
? rptOp.areaSize
: (Array.isArray(job.sprayAreas) && job.sprayAreas.length
? job.sprayAreas.reduce((s, a) => s + (a.properties?.area || 0), 0)
: null);
// Coverage: confirmed or sum of App.totalSprayed
const coverage_ha = reportConfirmed
? rptOp.coverage
: apps.reduce((s, a) => s + (a.totalSprayed || 0), 0);
// AppRate: confirmed or fallback to first AppFile.meta.appRate per requirements.
const appRate = reportConfirmed ? rptOp.appRate : firstMetaAppRate;
const sprayVolume = (utils.isNumber(coverage_ha) && utils.isNumber(appRate))
? coverage_ha * appRate
: null;
const useActualVolume = reportConfirmed ? !!(rptOp.useActualVol) : false;
const actualVolume = (reportConfirmed && useActualVolume) ? (rptOp.actualVol ?? null) : null;
const effectiveVolume = useActualVolume ? actualVolume : sprayVolume;
// Rate and volume units (derived from job setting)
const appRateUnitCode = utils.isNumber(job.appRateUnit) ? job.appRateUnit : null;
const appRateUnit = appRateUnitCode != null ? utils.rateUnitString(appRateUnitCode, true) : null;
const volumeUnit = appRateUnitCode != null ? utils.rateUnitString(appRateUnitCode, true, 1) : null;
const useCustomWeather = !!job.useCustWI;
const weather = (useCustomWeather && job.weatherInfo)
? {
windSpeed_kt: job.weatherInfo.windSpd ?? null,
windDir: job.weatherInfo.windDir ?? null,
temp_c: job.weatherInfo.temp ?? null,
humidity_pct: job.weatherInfo.humid ?? null
}
: null;
const overSprayedPct = (utils.isNumber(coverage_ha) && utils.isNumber(areaSize_ha) && areaSize_ha !== 0)
? ((coverage_ha - areaSize_ha) / areaSize_ha) * 100
: null;
return {
reportConfirmed,
areaSize_ha,
coverage_ha,
overSprayedPct,
appRate,
appRateUnit,
appRateConfirmed: reportConfirmed ? appRate : null,
sprayVolume,
volumeUnit,
useActualVolume,
actualVolume,
effectiveVolume,
useCustomWeather,
weather
};
}
/** Verify the job belongs to the authenticated owner (req.uid via byPuid). */
async function ownerJob(jobId, ownerId) {
const job = await Job.findOne({ _id: jobId, markedDelete: { $ne: true } })
.populate('operator', '_id name')
.populate('vehicle', '_id name tailNumber')
.populate('client', '_id name')
.lean();
if (!job) AppParamError.throw(Errors.JOB_NOT_FOUND);
if (!job.byPuid || job.byPuid.toString() !== ownerId.toString()) AppAuthError.throw();
return job;
}
// ─── Session Summary ─────────────────────────────────────────────────────────
/**
* GET /api/v1/jobs/:jobId/sessions
*
* Returns one summary record per uploaded application session (App + AppFile).
* Includes reportConfirmed block with fallback to raw aggregates.
*
* FE / integration note:
* - Poll this endpoint after the file-upload job status becomes "done".
* - Re-fetch when reportConfirmed changes from false to true (applicator confirms report).
*/
async function getSessions(req, res) {
const jobId = parseInt(req.params.jobId, 10);
if (!isFinite(jobId)) AppParamError.throw('invalid jobId');
const job = await ownerJob(jobId, req.uid);
// Get all non-deleted Apps for this job
const apps = await App.find({ jobId, markedDelete: { $ne: true } })
.sort({ createdDate: 1 })
.lean();
if (!apps.length) {
return res.json({ data: [], jobId, reportConfirmed: false });
}
const appIds = apps.map(a => a._id);
// Get all AppFiles grouped by appId
const appFiles = await AppFile.find({ appId: { $in: appIds }, markedDelete: { $ne: true } })
.sort({ agn: 1 })
.lean();
const filesByApp = {};
for (const f of appFiles) {
const key = f.appId.toString();
if (!filesByApp[key]) filesByApp[key] = [];
filesByApp[key].push(f);
}
const firstAppFile = appFiles.length ? appFiles[0] : null;
const firstMetaAppRate = firstAppFile?.meta?.appRate ?? null;
// Latest JobAssign for pilot traceability
const assign = await JobAssign.findOne({ jobId, status: { $gte: 0 } })
.sort({ createdAt: -1 })
.lean();
const confirmedBlock = buildConfirmedValues(job, apps, firstMetaAppRate);
const mappedArea_ha = Array.isArray(job.sprayAreas)
? job.sprayAreas.reduce((s, a) => s + (a?.properties?.area || 0), 0)
: null;
const sessions = apps.map(app => {
const files = filesByApp[app._id.toString()] || [];
const firstFile = files[0]; // primary file for metadata
const meta = firstFile?.meta || {};
return {
sessionId: app._id,
fileName: app.fileName,
startDateTime: app.startDateTime,
endDateTime: app.endDateTime,
// Timing
totalFlightTime_s: app.totalFlightTime ?? null,
totalSprayTime_s: app.totalSprayTime ?? null,
totalTurnTime_s: app.totalTurnTime ?? null,
// Application
totalSprayed_ha: app.totalSprayed ?? null,
totalSprayMat: app.totalSprayMat ?? null,
totalSprayMatUnit: utils.isNumber(app.totalSprayMatUnit) ? utils.rateUnitString(app.totalSprayMatUnit, true, 1) : null,
avgSpraySpeed_ms: app.avgSpraySpeed ?? null,
// File metadata (from first AppFile)
sprayZoneName: meta.areaOrZone ?? null,
sprayZoneArea_ha: meta.sprCoverage?.[1] ?? null,
appRate: meta.appRate ?? null,
appRateUnit: confirmedBlock.appRateUnit,
matType: meta.matType ?? null, // 'wet' | 'dry'
flowController: normaliseFlowController(meta.fcName),
sprayOnLag_s: meta.sprOnLag ?? null,
sprayOffLag_s: meta.sprOffLag ?? null,
pulsesPerLiter: meta.pulsesPerLit ?? null,
// Per-session files list (for consumers that need fileId to fetch records)
files: files.map(f => ({ fileId: f._id, name: f.name })),
// Pilot traceability
sessionPilotName: meta.operator ?? null, // name as recorded in the data file
pilotId: job.operator?._id ?? null,
pilotName: job.operator?.name ?? null, // assigned pilot on the job record
aircraftName: job.vehicle?.name ?? null,
aircraftTailNumber: job.vehicle?.tailNumber ?? null,
assignedDate: assign?.createdAt ?? null,
// Confirmed summary fields repeated per session for consumer convenience.
reportConfirmed: confirmedBlock.reportConfirmed,
areaSize_ha: confirmedBlock.areaSize_ha,
coverage_ha: confirmedBlock.coverage_ha,
appRateConfirmed: confirmedBlock.appRateConfirmed,
sprayVolume: confirmedBlock.sprayVolume,
volumeUnit: confirmedBlock.volumeUnit,
useActualVolume: confirmedBlock.useActualVolume,
actualVolume: confirmedBlock.actualVolume,
effectiveVolume: confirmedBlock.effectiveVolume
};
});
res.json({
jobId,
clientId: job.client?._id ?? null,
clientName: job.client?.name ?? null,
mappedArea_ha,
...confirmedBlock,
data: sessions
});
}
// ─── Raw GPS Trace Records ────────────────────────────────────────────────────
/**
* GET /api/v1/jobs/:jobId/sessions/:fileId/records
* Query: startingAfter, endingBefore, limit (default 500, max 2000), interval (seconds float)
*
* Returns cursor-paginated AppDetail records for one AppFile.
* sprayStat=3 (internal segment marker) is excluded.
* interval=N returns one record per N-second GPS time window (thinning for large exports).
*
* FE / integration note:
* - Use startingAfter cursor from previous page's last_id to paginate forward.
* - For Power BI incremental refresh: use interval=1 or interval=5 for overview.
* - For ArcGIS import: use the /export endpoint instead (full async download).
*/
async function getSessionRecords(req, res) {
const jobId = parseInt(req.params.jobId, 10);
const fileId = req.params.fileId;
if (!isFinite(jobId)) AppParamError.throw('invalid jobId');
if (!ObjectId.isValid(fileId)) AppParamError.throw('invalid fileId');
// Verify job ownership (also confirms job exists)
await ownerJob(jobId, req.uid);
// Verify the AppFile belongs to this job
const appFile = await AppFile.findOne({ _id: ObjectId(fileId), markedDelete: { $ne: true } }).lean();
if (!appFile) AppParamError.throw(Errors.NOT_FOUND);
// Verify the App (session) belongs to this job
const app = await App.findOne({ _id: appFile.appId, jobId }).lean();
if (!app) AppAuthError.throw();
const params = { ...req.query };
// Customer requirements use `after`; cursor helper expects `startingAfter`.
if (!params.startingAfter && params.after) params.startingAfter = params.after;
// Apply 2000-record hard cap for raw trace endpoint
if (!params.limit) params.limit = 500;
const requestedLimit = parseInt(params.limit);
if (requestedLimit > 2000) params.limit = 2000;
const validation = validateCursorParams(params);
if (!validation.valid) return res.status(HttpStatus.BAD_REQUEST).json({ error: validation.error });
const interval = parseInterval(params.interval);
const includeFm = params.fm === 'true'; // opt-in: ?fm=true adds Flight Master / AgDisp fields
const sessionMeta = appFile.meta || {};
// Base filter: exclude internal segment markers (sprayStat=3)
const baseFilter = { fileId: ObjectId(fileId), sprayStat: { $ne: 3 } };
const result = await paginateWithCursor(AppDetail, params, baseFilter, { cursorField: '_id' });
// Apply interval thinning if requested
let records = result.data || [];
if (interval && records.length) {
const thinned = [];
let windowStart = null;
for (const r of records) {
if (windowStart === null || (r.gpsTime - windowStart) >= interval) {
thinned.push(r);
windowStart = r.gpsTime;
}
}
records = thinned;
}
res.json({
...result,
data: records.map(d => mapDetailRecord(d, sessionMeta, app.startDateTime, includeFm))
});
}
// ─── Spray-Area GeoJSON Polygons ─────────────────────────────────────────────
/**
* GET /api/v1/jobs/:jobId/areas
*
* Returns the planned spray-area polygons as a GeoJSON FeatureCollection.
* Each Feature includes area metadata (name, planned appRate, area_ha) in properties.
*
* FE / integration note:
* - Import directly as an ArcGIS layer once the endpoint is confirmed.
* - This endpoint is gated on AMAGGI confirming the GeoJSON boundary requirement.
*/
async function getAreas(req, res) {
const jobId = parseInt(req.params.jobId, 10);
if (!isFinite(jobId)) AppParamError.throw('invalid jobId');
const job = await ownerJob(jobId, req.uid);
const appRateUnitCode = utils.isNumber(job.appRateUnit) ? job.appRateUnit : null;
const appRateUnit = appRateUnitCode != null ? utils.rateUnitString(appRateUnitCode, true) : null;
// area_ha fallback: confirmed report total → ttSprArea (total sprayable area)
const areaReportConfirmed = !!(job.rptOp && job.rptOp.coverage != null);
const fallbackAreaHa = (areaReportConfirmed
? (job.rptOp?.areaSize ?? job.ttSprArea)
: job.ttSprArea) ?? null;
const sprayFeatures = (job.sprayAreas || []).map(area => ({
type: 'Feature',
properties: {
name: area.properties?.name ?? null,
appRate: utils.isNumber(area.properties?.appRate) ? area.properties.appRate : (job.appRate ?? null),
appRateUnit,
appRateUnitCode,
area_ha: area.properties?.area ?? fallbackAreaHa,
type: ExportAreaTypes.AREA
},
geometry: area.geometry
}));
const xclFeatures = (job.excludedAreas || []).map(area => ({
type: 'Feature',
properties: {
name: area.properties?.name ?? null,
type: ExportAreaTypes.EXCLUDED
},
geometry: area.geometry
}));
const features = sprayFeatures.concat(xclFeatures);
res.json({
type: 'FeatureCollection',
jobId,
features
});
}
module.exports = { getSessions, getSessionRecords, getAreas };