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