'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 };