agmission/server/controllers/api_pub.js

650 lines
27 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 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 };