367 lines
13 KiB
JavaScript
367 lines
13 KiB
JavaScript
'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 } = 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;
|
||
}
|
||
|
||
/**
|
||
* Map a raw AppDetail document to the public API record shape.
|
||
* sessionMeta contains session-constant fields from AppFile.meta injected once per page.
|
||
*/
|
||
function mapDetailRecord(d, sessionMeta) {
|
||
const { correctionId, waasId } = decodeCorrectionFields(d.tslu, d.calcodeFreq);
|
||
return {
|
||
// GPS Data
|
||
timestampUtc: d.gpsTime ? moment.unix(d.gpsTime).utc().toISOString() : null,
|
||
gpsTime: d.gpsTime,
|
||
lat: d.lat,
|
||
lon: d.lon,
|
||
utmX: d.utmX,
|
||
utmY: d.utmY,
|
||
alt: d.alt,
|
||
groundSpeed: d.grSpeed,
|
||
heading: d.head,
|
||
crossTrackError: 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: computeAppRateApplied(d.lminApp, d.grSpeed, d.swath),
|
||
swathWidth: d.swath,
|
||
boomPressure_psi: d.psi,
|
||
// Session-constant fields from AppFile.meta (repeated per record for flat-file consumers)
|
||
sprayOnLag_s: sessionMeta?.sprOnLag ?? null,
|
||
sprayOffLag_s: sessionMeta?.sprOffLag ?? null,
|
||
pulsesPerLitre: sessionMeta?.pulsesPerLit ?? null,
|
||
rpm: d.rpm,
|
||
// MET
|
||
windSpeed_ms: d.windSpd,
|
||
windDir_deg: d.windDir,
|
||
temp_c: d.temp,
|
||
humidity_pct: d.humid
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
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 null (cannot reliably aggregate across files with different units)
|
||
const appRate = reportConfirmed ? rptOp.appRate : null;
|
||
|
||
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;
|
||
|
||
const result = { reportConfirmed, areaSize_ha, coverage_ha, appRate, sprayVolume, useActualVolume, actualVolume, effectiveVolume };
|
||
|
||
// Custom weather — only include when manually entered
|
||
if (job.useCustWI && job.weatherInfo) {
|
||
result.customWeather = {
|
||
windSpeed_kt: job.weatherInfo.windSpd ?? null,
|
||
windDir: job.weatherInfo.windDir ?? null,
|
||
temp_c: job.weatherInfo.temp ?? null,
|
||
humidity_pct: job.weatherInfo.humid ?? null
|
||
};
|
||
} else {
|
||
result.customWeather = null;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/** 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')
|
||
.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);
|
||
}
|
||
|
||
// Latest JobAssign for pilot traceability
|
||
const assign = await JobAssign.findOne({ jobId, status: { $gte: 0 } })
|
||
.sort({ createdAt: -1 })
|
||
.lean();
|
||
|
||
const confirmedBlock = buildConfirmedValues(job, apps);
|
||
|
||
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,
|
||
status: app.status,
|
||
proStatus: app.proStatus,
|
||
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: app.totalSprayMatUnit ?? 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: meta.appRateUnitStr ?? null,
|
||
matType: meta.matType ?? null, // 'wet' | 'dry'
|
||
flowController: meta.fcName ?? null,
|
||
sprayOnLag_s: meta.sprOnLag ?? null,
|
||
sprayOffLag_s: meta.sprOffLag ?? null,
|
||
pulsesPerLitre: 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, agn: f.agn })),
|
||
// Pilot traceability
|
||
sessionPilotName: meta.operator ?? null, // name as recorded in the data file
|
||
pilotId: job.operator?._id ?? null,
|
||
pilotName: job.operator?.name ?? null,
|
||
aircraftName: job.vehicle?.name ?? null,
|
||
aircraftTailNumber: job.vehicle?.tailNumber ?? null,
|
||
assignedDate: assign?.createdAt ?? null
|
||
};
|
||
});
|
||
|
||
res.json({
|
||
jobId,
|
||
...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 };
|
||
// 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 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))
|
||
});
|
||
}
|
||
|
||
// ─── 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 features = (job.sprayAreas || []).map(area => ({
|
||
type: 'Feature',
|
||
properties: {
|
||
name: area.properties?.name ?? null,
|
||
appRate: area.properties?.appRate ?? null,
|
||
area_ha: area.properties?.area ?? null,
|
||
type: area.properties?.type ?? null
|
||
},
|
||
geometry: area.geometry
|
||
}));
|
||
|
||
res.json({
|
||
type: 'FeatureCollection',
|
||
jobId,
|
||
features
|
||
});
|
||
}
|
||
|
||
module.exports = { getSessions, getSessionRecords, getAreas };
|