agmission/Development/server/controllers/api_pub.js

367 lines
13 KiB
JavaScript
Raw 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:
* 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 };