650 lines
27 KiB
JavaScript
650 lines
27 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:
|
||
* Query: startingAfter, endingBefore, limit (default 500, max configured by PUBLIC_API_RECORDS_MAX_LIMIT), interval (seconds float)
|
||
* GET /api/v1/jobs/:jobId/sessions/:fileId/records → raw GPS trace (paginated)
|
||
* GET /api/v1/jobs/:jobId/areas → GeoJSON spray-area polygons
|
||
* interval=N returns one record per N-second GPS time window (thinning for large exports),
|
||
* while always keeping records where sprayStat changes.
|
||
*/
|
||
|
||
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, RateUnits, UserTypes } = require('../helpers/constants');
|
||
const utils = require('../helpers/utils');
|
||
const env = require('../helpers/env');
|
||
const { computeAppRateApplied, flowRateFromAppRate, isPositiveNumber, inferRateUnitCode, isLikelyLiquidMaterial, resolveTargetRatePerHa } = require('../helpers/record_utils');
|
||
|
||
const DEFAULT_RECORDS_LIMIT = 500;
|
||
const MAX_RECORDS_LIMIT = Math.max(DEFAULT_RECORDS_LIMIT, Number(env.PUBLIC_API_RECORDS_MAX_LIMIT) || 2000);
|
||
|
||
// ─── 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;
|
||
}
|
||
|
||
/**
|
||
* Cursor paginate AppDetail with interval thinning applied before page slicing.
|
||
* This keeps thinning behavior consistent across page boundaries.
|
||
*/
|
||
async function paginateThinnedAppDetails({ fileId, startingAfter, limit, interval }) {
|
||
const fileObjectId = ObjectId(fileId);
|
||
const rawBatchSize = Math.min(Math.max(limit * 10, 500), 5000);
|
||
const kept = [];
|
||
|
||
let lastScannedId = startingAfter ? ObjectId(startingAfter) : null;
|
||
let windowStart = null;
|
||
let prevSprayStat = null;
|
||
|
||
if (lastScannedId) {
|
||
const seed = await AppDetail.findOne({ _id: lastScannedId, fileId: fileObjectId }, { gpsTime: 1, sprayStat: 1 }).lean();
|
||
if (!seed) AppParamError.throw('invalid startingAfter cursor for this file');
|
||
windowStart = utils.isNumber(seed.gpsTime) ? seed.gpsTime : null;
|
||
prevSprayStat = seed.sprayStat ?? null;
|
||
}
|
||
|
||
while (kept.length < (limit + 1)) {
|
||
const filter = { fileId: fileObjectId };
|
||
if (lastScannedId) filter._id = { $gt: lastScannedId };
|
||
|
||
const batch = await AppDetail.find(filter).sort({ _id: 1 }).limit(rawBatchSize).lean();
|
||
if (!batch.length) break;
|
||
|
||
for (const r of batch) {
|
||
const sprayStatChanged = prevSprayStat !== null && r.sprayStat !== prevSprayStat;
|
||
const canCompareWindow = utils.isNumber(r.gpsTime) && utils.isNumber(windowStart);
|
||
// Some legacy datasets have non-monotonic gpsTime in _id order; reset interval window when time moves backward.
|
||
const gpsTimeBackwards = canCompareWindow && r.gpsTime < windowStart;
|
||
const keep = windowStart === null || sprayStatChanged || gpsTimeBackwards || !canCompareWindow || (r.gpsTime - windowStart) >= interval;
|
||
|
||
if (keep) {
|
||
kept.push(r);
|
||
windowStart = utils.isNumber(r.gpsTime) ? r.gpsTime : windowStart;
|
||
if (kept.length >= (limit + 1)) {
|
||
lastScannedId = r._id;
|
||
break;
|
||
}
|
||
}
|
||
|
||
prevSprayStat = r.sprayStat;
|
||
lastScannedId = r._id;
|
||
}
|
||
|
||
if (kept.length >= (limit + 1)) break;
|
||
lastScannedId = batch[batch.length - 1]._id;
|
||
}
|
||
|
||
const hasMore = kept.length > limit;
|
||
const page = hasMore ? kept.slice(0, limit) : kept;
|
||
|
||
return {
|
||
data: page,
|
||
hasMore,
|
||
startingAfter: page.length ? page[page.length - 1]._id : undefined,
|
||
endingBefore: page.length ? page[0]._id : undefined
|
||
};
|
||
}
|
||
|
||
function roundIfNumber(value, decimals) {
|
||
return utils.isNumber(value) ? utils.roundTo(value, decimals) : value;
|
||
}
|
||
|
||
function roundGeoJsonCoordinates(coordinates, decimals = 7) {
|
||
if (!Array.isArray(coordinates)) return coordinates;
|
||
return coordinates.map(item => {
|
||
if (Array.isArray(item)) return roundGeoJsonCoordinates(item, decimals);
|
||
return utils.isNumber(item) ? utils.roundTo(item, decimals) : item;
|
||
});
|
||
}
|
||
|
||
function roundGeoJsonGeometry(geometry) {
|
||
if (!geometry || !geometry.type || !Array.isArray(geometry.coordinates)) return geometry;
|
||
return {
|
||
...geometry,
|
||
coordinates: roundGeoJsonCoordinates(geometry.coordinates, 7)
|
||
};
|
||
}
|
||
|
||
function isLiquidMaterialFromRateUnit(rateUnitCode) {
|
||
return rateUnitCode === RateUnits.OZ_PER_ACRE
|
||
|| rateUnitCode === RateUnits.GAL_PER_ACRE
|
||
|| rateUnitCode === RateUnits.LIT_PER_HA;
|
||
}
|
||
|
||
function normalizeSprayMatToMetricBase(value, totalSprayMatUnit, isLiquidMaterial) {
|
||
if (!utils.isNumber(value)) return 0;
|
||
|
||
if (isLiquidMaterial) {
|
||
// Historical datasets may encode gallon as 4 even though current constants use 1.
|
||
const isStoredGallons = totalSprayMatUnit === RateUnits.GAL_PER_ACRE || totalSprayMatUnit === 4;
|
||
return isStoredGallons ? utils.toMetricVolume(value, true, true) : value;
|
||
}
|
||
|
||
const isStoredPounds = totalSprayMatUnit === RateUnits.LBS_PER_ACRE;
|
||
return isStoredPounds ? utils.toMetricVolume(value, false, true) : value;
|
||
}
|
||
|
||
|
||
/**
|
||
* 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 getJobMappedAreaHa(job) {
|
||
if (utils.isNumber(job?.rptOp?.areaSize)) return job.rptOp.areaSize;
|
||
if (utils.isNumber(job?.ttSprArea)) return job.ttSprArea;
|
||
return null;
|
||
}
|
||
|
||
function mapDetailRecord(d, sessionMeta, appStartDateTime, job, includeFm = false) {
|
||
// sprayStat: 0=off, 1=on, 3=segment marker. Only compute rates for actual spray-on records (1)
|
||
const sprayOn = d.sprayStat === 1;
|
||
const rateUnitCode = inferRateUnitCode(sessionMeta, job);
|
||
const liquidMaterial = isLikelyLiquidMaterial(sessionMeta, rateUnitCode);
|
||
const targetRateMetric = resolveTargetRatePerHa(sessionMeta, job);
|
||
const metaAppRate = sessionMeta?.appRate;
|
||
|
||
// flowRate fields: raw stored values, no fallback (matches frontend)
|
||
const flowRateApplied = d.lminApp ?? null;
|
||
const flowRateRequired = d.lminReq ?? null;
|
||
|
||
// appRateRequired: matches frontend applicRate display (metric output)
|
||
// Priority 1: meta.appRate (converted to metric) when present
|
||
// Priority 2: per-point lhaReq when present
|
||
// Priority 3: job.appRate (converted to metric) as fallback
|
||
const appRateRequired = (utils.isNumber(metaAppRate) && metaAppRate !== 0)
|
||
? targetRateMetric
|
||
: (utils.isNumber(d.lhaReq) ? d.lhaReq : targetRateMetric);
|
||
|
||
// appRateApplied: matches frontend appRateAp — only meaningful when spraying
|
||
// Priority 1: meta.appRate (metric) when no FC or FC has no reading
|
||
// Priority 2: liquid — compute from measured flow rate (L/min → L/ha)
|
||
// Priority 3: dry/granular — lminApp stores kg/ha directly
|
||
let appRateApplied = null;
|
||
if (sprayOn) {
|
||
const useFC = sessionMeta?.useFC;
|
||
if (metaAppRate && (!useFC || !d.lminApp)) {
|
||
appRateApplied = targetRateMetric;
|
||
} else if (liquidMaterial) {
|
||
appRateApplied = utils.appRateFromFlowRate(d.lminApp, d.swath, d.grSpeed);
|
||
} else {
|
||
appRateApplied = utils.isNumber(d.lminApp) ? d.lminApp : null;
|
||
}
|
||
}
|
||
const pulsesPerLiter = sessionMeta?.pulsesPerLit ?? null;
|
||
const rec = {
|
||
// GPS Data
|
||
timeUtc: toRecordTimeUtc(d.gpsTime, appStartDateTime),
|
||
gpsTime: d.gpsTime,
|
||
lat: utils.isNumber(d.lat) ? utils.roundTo(d.lat, 7) : d.lat,
|
||
lon: utils.isNumber(d.lon) ? utils.roundTo(d.lon, 7) : d.lon,
|
||
utmX: utils.isNumber(d.utmX) ? utils.roundTo(d.utmX, 1) : d.utmX,
|
||
utmY: utils.isNumber(d.utmY) ? utils.roundTo(d.utmY, 1) : d.utmY,
|
||
alt: utils.isNumber(d.alt) ? utils.roundTo(d.alt, 2) : d.alt,
|
||
grSpeed: utils.isNumber(d.grSpeed) ? utils.roundTo(d.grSpeed, 2) : d.grSpeed,
|
||
heading: utils.isNumber(d.head) ? utils.roundTo(d.head, 2) : d.head,
|
||
xTrack: utils.isNumber(d.xTrack) ? utils.roundTo(d.xTrack, 2) : d.xTrack,
|
||
lockedLine: d.llnum,
|
||
hdop: utils.isNumber(d.stdHdop) ? utils.roundTo(d.stdHdop, 2) : d.stdHdop,
|
||
satsIn: d.satsIn,
|
||
tslu: d.tslu,
|
||
calcodeFreq: d.calcodeFreq,
|
||
sprayStat: d.sprayStat,
|
||
// Application Info
|
||
flowRateApplied: utils.isNumber(flowRateApplied) ? utils.roundTo(flowRateApplied, 4) : flowRateApplied,
|
||
flowRateRequired: utils.isNumber(flowRateRequired) ? utils.roundTo(flowRateRequired, 4) : flowRateRequired,
|
||
appRateRequired: utils.isNumber(appRateRequired) ? utils.roundTo(appRateRequired, 4) : appRateRequired,
|
||
appRateApplied: utils.isNumber(appRateApplied) ? utils.roundTo(appRateApplied, 4) : appRateApplied,
|
||
swathWidth: isPositiveNumber(d.swath) ? d.swath : (job?.swathWidth ?? d.swath),
|
||
boomPressure_psi: utils.isNumber(d.psi) ? utils.roundTo(d.psi, 2) : 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) ? utils.roundTo(d.windSpd * 1.94384, 2) : null,
|
||
windDir_deg: utils.isNumber(d.windDir) ? utils.roundTo(d.windDir, 1) : d.windDir,
|
||
temp_c: utils.isNumber(d.temp) ? utils.roundTo(d.temp, 1) : d.temp,
|
||
humidity_pct: utils.isNumber(d.humid) ? utils.roundTo(d.humid, 1) : 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 = utils.isNumber(d.sprayHeight) ? utils.roundTo(d.sprayHeight, 2) : (d.sprayHeight ?? null);
|
||
rec.driftX_m = utils.isNumber(d.driftX) ? utils.roundTo(d.driftX, 2) : (d.driftX ?? null);
|
||
rec.driftY_m = utils.isNumber(d.driftY) ? utils.roundTo(d.driftY, 2) : (d.driftY ?? null);
|
||
rec.depositX_m = utils.isNumber(d.depositX) ? utils.roundTo(d.depositX, 2) : (d.depositX ?? null);
|
||
rec.depositY_m = utils.isNumber(d.depositY) ? utils.roundTo(d.depositY, 2) : (d.depositY ?? null);
|
||
rec.radarAlt_m = utils.isNumber(d.radarAlt) ? utils.roundTo(d.radarAlt, 2) : (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);
|
||
const isUS = !!job.measureUnit;
|
||
|
||
// Area size: confirmed report area or fallback total sprayable area from Job.
|
||
const areaSize_ha = reportConfirmed
|
||
? rptOp.areaSize
|
||
: getJobMappedAreaHa(job);
|
||
|
||
// 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;
|
||
|
||
// 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;
|
||
// Determine material type from job's rate unit setting.
|
||
// Liquid: OZ_PER_ACRE, GAL_PER_ACRE, LIT_PER_HA; Solid: LBS_PER_ACRE, KG_PER_HA.
|
||
// Default to liquid when appRateUnit is not set (most common case).
|
||
const liquidMaterial = appRateUnitCode != null ? isLiquidMaterialFromRateUnit(appRateUnitCode) : true;
|
||
|
||
// Stored App.totalSprayMat is metric-base for most flows; normalize any gallon/lbs legacy values to metric base.
|
||
const summedSprayVolumeMetric = apps?.reduce(
|
||
(s, a) => s + normalizeSprayMatToMetricBase(a?.totalSprayMat, a?.totalSprayMatUnit, liquidMaterial),
|
||
0
|
||
);
|
||
|
||
// Planned/estimated spray volume shown in Report Settings dialog:
|
||
// total spray area (coverage) × app rate.
|
||
const sprayVolumeMetricByRate = (utils.isNumber(coverage_ha) && utils.isNumber(appRate))
|
||
? coverage_ha * appRate
|
||
: null;
|
||
|
||
// Actual spray volume calculated from imported applications.
|
||
const actualSprayVolumeMetric = summedSprayVolumeMetric > 0 ? summedSprayVolumeMetric : null;
|
||
|
||
// Confirmed actual spray volume entered in Report Settings dialog.
|
||
const confirmedActualVolumeMetric = utils.isNumber(rptOp?.actualVol) ? rptOp.actualVol : null;
|
||
|
||
// Keep field name for API response: sprayVolume = planned estimate.
|
||
const sprayVolumeMetric = sprayVolumeMetricByRate;
|
||
|
||
const actualSprayVolume = actualSprayVolumeMetric != null
|
||
? utils.toVolume(actualSprayVolumeMetric, liquidMaterial, isUS)
|
||
: null;
|
||
const confirmedActualVolume = confirmedActualVolumeMetric != null
|
||
? utils.toVolume(confirmedActualVolumeMetric, liquidMaterial, isUS)
|
||
: null;
|
||
const useConfirmedVolume = reportConfirmed ? !!(rptOp?.useActualVol) : false;
|
||
const effectiveVolumeMetric = useConfirmedVolume
|
||
? confirmedActualVolumeMetric
|
||
: actualSprayVolumeMetric;
|
||
const effectiveVolume = effectiveVolumeMetric != null
|
||
? utils.toVolume(effectiveVolumeMetric, liquidMaterial, isUS)
|
||
: null;
|
||
const volumeUnit = liquidMaterial ? (isUS ? 'gal' : 'lit') : (isUS ? 'lb' : 'kg');
|
||
|
||
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 overSprayed_pct = (utils.isNumber(coverage_ha) && utils.isNumber(areaSize_ha) && areaSize_ha !== 0)
|
||
? utils.roundTo(((coverage_ha - areaSize_ha) / areaSize_ha) * 100, 2)
|
||
: null;
|
||
|
||
return {
|
||
reportConfirmed,
|
||
areaSize_ha: utils.isNumber(areaSize_ha) ? utils.roundTo(areaSize_ha, 2) : null,
|
||
coverage_ha: utils.isNumber(coverage_ha) ? utils.roundTo(coverage_ha, 2) : null,
|
||
overSprayed_pct,
|
||
appRate,
|
||
appRateUnit,
|
||
appRateConfirmed: reportConfirmed ? appRate : null,
|
||
sprayVolume: utils.isNumber(sprayVolumeMetric)
|
||
? utils.roundTo(utils.toVolume(sprayVolumeMetric, liquidMaterial, isUS), 3)
|
||
: null,
|
||
volumeUnit,
|
||
useConfirmedVolume,
|
||
actualSprayVolume: utils.isNumber(actualSprayVolume) ? utils.roundTo(actualSprayVolume, 3) : actualSprayVolume,
|
||
confirmedActualVolume: utils.isNumber(confirmedActualVolume) ? utils.roundTo(confirmedActualVolume, 3) : confirmedActualVolume,
|
||
effectiveVolume: utils.isNumber(effectiveVolume) ? utils.roundTo(effectiveVolume, 3) : null,
|
||
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 aircraft traceability (currently only used for DEVICE assignments)
|
||
const assign = await JobAssign.findOne({ job: jobId, status: { $gte: 0 } })
|
||
.sort({ date: -1 })
|
||
.populate({
|
||
path: 'user',
|
||
select: '_id name kind tailNumber',
|
||
match: { active: true, markedDelete: { $ne: true } }
|
||
})
|
||
.lean();
|
||
|
||
// Determine assigned aircraft from latest live JobAssign (DEVICE only).
|
||
// Do not fall back to plan aircraft here — plan fields are returned separately.
|
||
let assignedAircraftId = null;
|
||
let assignedAircraftName = null;
|
||
let assignedAircraftTailNumber = null;
|
||
|
||
if (assign?.user?.kind === UserTypes.DEVICE) {
|
||
assignedAircraftId = assign.user._id ?? null;
|
||
assignedAircraftName = assign.user.name ?? null;
|
||
assignedAircraftTailNumber = assign.user.tailNumber ?? null;
|
||
}
|
||
|
||
const confirmedBlock = buildConfirmedValues(job, apps, firstMetaAppRate);
|
||
const rawMappedArea = getJobMappedAreaHa(job);
|
||
const mappedArea_ha = utils.isNumber(rawMappedArea) ? utils.roundTo(rawMappedArea, 2) : 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: utils.isNumber(app.totalFlightTime) ? utils.roundTo(app.totalFlightTime, 3) : null,
|
||
totalSprayTime_s: utils.isNumber(app.totalSprayTime) ? utils.roundTo(app.totalSprayTime, 3) : null,
|
||
totalTurnTime_s: utils.isNumber(app.totalTurnTime) ? utils.roundTo(app.totalTurnTime, 3) : null,
|
||
// Application
|
||
totalSprayed_ha: utils.isNumber(app.totalSprayed) ? utils.roundTo(app.totalSprayed, 2) : null,
|
||
totalSprayMat: utils.isNumber(app.totalSprayMat) ? utils.roundTo(app.totalSprayMat, 3) : null,
|
||
totalSprayMatUnit: utils.isNumber(app.totalSprayMatUnit) ? utils.rateUnitString(app.totalSprayMatUnit, true, 1) : null,
|
||
avgSpraySpeed_ms: utils.isNumber(app.avgSpraySpeed) ? utils.roundTo(app.avgSpraySpeed, 2) : null,
|
||
// File metadata (from first AppFile)
|
||
sprayZoneName: meta.areaOrZone ?? null,
|
||
sprayZoneArea_ha: utils.isNumber(meta.sprCoverage?.[1]) ? utils.roundTo(meta.sprCoverage[1], 2) : null,
|
||
appRate: meta.appRate ?? null,
|
||
appRateUnit: confirmedBlock.appRateUnit,
|
||
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 name as recorded in the data file (may differ from job-assigned pilot)
|
||
sessionPilotName: meta.operator ?? null
|
||
};
|
||
});
|
||
|
||
res.json({
|
||
jobId,
|
||
clientId: job.client?._id ?? null,
|
||
clientName: job.client?.name ?? null,
|
||
assignedPilotId: job.operator?._id ?? null,
|
||
assignedPilotName: job.operator?.name ?? null,
|
||
assignedAircraftId,
|
||
assignedAircraftName,
|
||
assignedAircraftTailNumber,
|
||
planAircraftName: job.vehicle?.name ?? null,
|
||
planAircraftTailNumber: job.vehicle?.tailNumber ?? null,
|
||
assignedDate: assign?.date ?? 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 configured by PUBLIC_API_RECORDS_MAX_LIMIT), interval (seconds float)
|
||
*
|
||
* Returns cursor-paginated AppDetail records for one AppFile.
|
||
* interval=N returns one record per N-second GPS time window (thinning for large exports).
|
||
* Records where sprayStat changed (spray-on/off events) are always included regardless of interval.
|
||
* Use interval=0 (or omit interval) to disable thinning.
|
||
*
|
||
* 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)
|
||
const job = 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) exists and belongs to this job.
|
||
// NOTE: legacy Apps may have jobId: null (pre-dates the jobId denormalization).
|
||
// In that case trust the ownerJob() check above — the only way a caller has the fileId
|
||
// is through the /sessions endpoint which already enforces ownership.
|
||
const app = await App.findOne({ _id: appFile.appId }).lean();
|
||
if (!app) AppParamError.throw(Errors.NOT_FOUND);
|
||
|
||
const params = { ...req.query };
|
||
// Customer requirements use `after`; cursor helper expects `startingAfter`.
|
||
if (!params.startingAfter && params.after) params.startingAfter = params.after;
|
||
// Apply env-backed hard cap for raw trace endpoint.
|
||
const requestedLimit = parseInt(params.limit, 10);
|
||
const normalizedLimit = isFinite(requestedLimit) && requestedLimit > 0
|
||
? requestedLimit
|
||
: DEFAULT_RECORDS_LIMIT;
|
||
params.limit = Math.min(normalizedLimit, MAX_RECORDS_LIMIT);
|
||
|
||
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: return all records, including spray-state markers.
|
||
const baseFilter = { fileId: ObjectId(fileId) };
|
||
|
||
let result;
|
||
if (interval) {
|
||
if (params.endingBefore) {
|
||
AppParamError.throw('endingBefore is not supported when interval > 0; use startingAfter pagination');
|
||
}
|
||
result = await paginateThinnedAppDetails({
|
||
fileId,
|
||
startingAfter: params.startingAfter,
|
||
limit: Number(params.limit),
|
||
interval
|
||
});
|
||
} else {
|
||
result = await paginateWithCursor(AppDetail, params, baseFilter, { cursorField: '_id' });
|
||
}
|
||
|
||
res.json({
|
||
...result,
|
||
data: (result.data || []).map(d => mapDetailRecord(d, sessionMeta, app.startDateTime, job, 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: roundIfNumber(
|
||
utils.isNumber(area.properties?.appRate) ? area.properties.appRate : (job.appRate ?? null),
|
||
2
|
||
),
|
||
appRateUnit,
|
||
appRateUnitCode,
|
||
area_ha: roundIfNumber(area.properties?.area ?? fallbackAreaHa, 2),
|
||
type: ExportAreaTypes.AREA
|
||
},
|
||
geometry: roundGeoJsonGeometry(area.geometry)
|
||
}));
|
||
|
||
const xclFeatures = (job.excludedAreas || []).map(area => ({
|
||
type: 'Feature',
|
||
properties: {
|
||
name: area.properties?.name ?? null,
|
||
type: ExportAreaTypes.EXCLUDED
|
||
},
|
||
geometry: roundGeoJsonGeometry(area.geometry)
|
||
}));
|
||
|
||
const features = sprayFeatures.concat(xclFeatures);
|
||
|
||
res.json({
|
||
type: 'FeatureCollection',
|
||
jobId,
|
||
features
|
||
});
|
||
}
|
||
|
||
module.exports = { getSessions, getSessionRecords, getAreas };
|