agmission/Development/server/controllers/dashboard.js

665 lines
24 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';
const debug = require('debug')('agm:dashboard');
const ObjectId = require('mongodb').ObjectId;
const { Job, App, AppFile, AppDetail } = require('../model');
const { JobStatus } = require('../helpers/job_constants');
const { Errors } = require('../helpers/constants');
const { AppAuthError, AppParamError, AppError } = require('../helpers/app_error');
// ─── Constants ────────────────────────────────────────────────────────────────
/** Job statuses shown in the Active Jobs panel. INVOICED (5) and ARCHIVED (9) are excluded. */
const ACTIVE_JOB_STATUSES = [
JobStatus.NEW,
JobStatus.READY,
JobStatus.DOWNLOADED,
JobStatus.SPRAYED,
JobStatus.COMPLETED
];
/** XT cross-track error thresholds (meters). */
const XT_GOOD = 1.0;
const XT_MONITOR = 3.0;
/** Altitude thresholds (meters). Target ~3.7 m. */
const ALT_TARGET = 3.7;
const ALT_GOOD = 0.15; // ±0.15 m of target
const ALT_MONITOR = 0.46; // ±0.46 m of target
// ─── Timezone helpers ─────────────────────────────────────────────────────────
/**
* Validate an IANA timezone string. Returns 'UTC' if invalid or missing.
*/
function validateTz(tz) {
if (!tz || typeof tz !== 'string') return 'UTC';
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return tz;
} catch {
return 'UTC';
}
}
/**
* Return a 'YYYY-MM-DD' date label for a Date in the given timezone.
* Uses 'en-CA' locale which formats as YYYY-MM-DD natively.
*/
function toDateLabel(date, tz) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit'
}).format(date);
}
/**
* Return the UTC Date corresponding to midnight 00:00:00 of dateLabel in tz.
*
* Strategy: format noon-UTC as local time in tz (via Intl), parse that back as
* UTC to measure the timezone offset, then apply that offset to naive midnight.
* This handles DST correctly because noon on most days is not a DST boundary.
*/
function midnightUTC(dateLabel, tz) {
const noonUTC = new Date(`${dateLabel}T12:00:00.000Z`);
const localStr = new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
}).format(noonUTC);
// en-CA produces "YYYY-MM-DD, HH:mm:ss"
const [datePart, timePart] = localStr.split(', ');
const localNoon = new Date(`${datePart}T${timePart}Z`); // parse as UTC to get naive value
const offsetMs = noonUTC.getTime() - localNoon.getTime();
const naiveUTC = new Date(`${dateLabel}T00:00:00.000Z`);
return new Date(naiveUTC.getTime() + offsetMs);
}
/** { start, end } UTC range for a day that is daysAgo before today in tz (0 = today). */
function dayWindow(daysAgo, tz) {
const now = new Date();
const todayLabel = toDateLabel(now, tz);
const pivot = new Date(`${todayLabel}T12:00:00.000Z`);
pivot.setUTCDate(pivot.getUTCDate() - daysAgo);
const targetLabel = toDateLabel(pivot, tz);
const start = midnightUTC(targetLabel, tz);
const end = new Date(start.getTime() + 86400000);
return { start, end };
}
/** { start, end } UTC range for the current MonSun calendar week in tz. */
function weekWindow(tz) {
const now = new Date();
const todayLabel = toDateLabel(now, tz);
const pivot = new Date(`${todayLabel}T12:00:00.000Z`);
const sinceMonday = (pivot.getUTCDay() + 6) % 7; // Mon=0 … Sun=6
const monPivot = new Date(pivot);
monPivot.setUTCDate(monPivot.getUTCDate() - sinceMonday);
const sunPivot = new Date(monPivot);
sunPivot.setUTCDate(sunPivot.getUTCDate() + 6);
const start = midnightUTC(toDateLabel(monPivot, tz), tz);
const end = new Date(midnightUTC(toDateLabel(sunPivot, tz), tz).getTime() + 86400000);
return { start, end };
}
/** { start, end } UTC range for the current calendar month in tz. */
function monthWindow(tz) {
const now = new Date();
const [year, month] = toDateLabel(now, tz).split('-');
const start = midnightUTC(`${year}-${month}-01`, tz);
const end = new Date(start);
end.setUTCMonth(end.getUTCMonth() + 1);
return { start, end };
}
/** { start, end } UTC range for the current calendar year in tz. */
function yearWindow(tz) {
const now = new Date();
const [year] = toDateLabel(now, tz).split('-');
const start = midnightUTC(`${year}-01-01`, tz);
const end = new Date(start);
end.setUTCFullYear(end.getUTCFullYear() + 1);
return { start, end };
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function round2(n) {
return Math.round((n || 0) * 100) / 100;
}
function safePct(current, previous) {
if (!previous) return null;
return Math.round(((current - previous) / previous) * 100);
}
/** Build Application base match for a set of jobIds (processed apps only). */
function appMatch(jobIds) {
return { jobId: { $in: jobIds }, status: 3, markedDelete: { $ne: true } };
}
/** Fetch all pilot job documents (light projection). */
async function fetchPilotJobs(pilotId) {
return Job.find(
{ operator: ObjectId(pilotId), markedDelete: { $ne: true } },
{ _id: 1, ttSprArea: 1, status: 1, byPuid: 1, name: 1, client: 1, vehicle: 1, endDate: 1, createdAt: 1 }
).lean();
}
// ─── GET /api/dashboard/pilot/kpi ────────────────────────────────────────────
/**
* @api {get} /api/dashboard/pilot/kpi Pilot KPI Cards
* @apiName GetPilotKpi
* @apiGroup PilotDashboard
* @apiDescription Returns KPI card data for the authenticated pilot.
* All Application metrics are based on createdDate (upload time) since
* Application.endDateTime is a raw device string with no guaranteed format.
*
* @apiQuery {String} [tz=UTC] IANA timezone string for today/week/month/year boundaries.
*
* @apiSuccess {Number} assignedJobs All-time total jobs assigned to this pilot.
* @apiSuccess {Number} assignedHectares Sum of ttSprArea across all assigned jobs.
* @apiSuccess {Number} sprayedToday Hectares sprayed today (by upload time).
* @apiSuccess {Number} flightHoursToday Flight hours recorded today.
* @apiSuccess {Object} operations distanceKm and sprayVolumeLiters for today.
* @apiSuccess {Object} historical Breakdown of jobs/hectares/flightHours by period.
*/
async function getKpi(req, res) {
if (!req.uid) AppAuthError.throw();
const tz = validateTz(req.query.tz);
const todayW = dayWindow(0, tz);
const weekW = weekWindow(tz);
const monthW = monthWindow(tz);
const yearW = yearWindow(tz);
const jobs = await fetchPilotJobs(req.uid);
const jobIds = jobs.map(j => j._id);
const assignedJobs = jobs.length;
const assignedHectares = round2(jobs.reduce((s, j) => s + (j.ttSprArea || 0), 0));
if (!jobIds.length) {
return res.json({
assignedJobs,
assignedHectares,
sprayedToday: 0,
flightHoursToday: 0,
operations: { distanceKm: 0, sprayVolumeLiters: 0 },
historical: {
jobs: { year: 0, month: 0, week: 0, day: 0 },
hectares: { year: 0, month: 0, week: 0, day: 0 },
flightHours: { year: 0, month: 0, week: 0, day: 0 }
}
});
}
const base = appMatch(jobIds);
// Run all aggregations in parallel
const [todayR, weekR, monthR, yearR, wJobs, mJobs, yJobs] = await Promise.all([
App.aggregate([
{ $match: { ...base, createdDate: { $gte: todayW.start, $lt: todayW.end } } },
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, distance: { $sum: '$totalSprLength' }, volume: { $sum: '$totalSprayMat' } } }
]),
App.aggregate([
{ $match: { ...base, createdDate: { $gte: weekW.start, $lt: weekW.end } } },
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
]),
App.aggregate([
{ $match: { ...base, createdDate: { $gte: monthW.start, $lt: monthW.end } } },
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
]),
App.aggregate([
{ $match: { ...base, createdDate: { $gte: yearW.start, $lt: yearW.end } } },
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
]),
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: weekW.start, $lt: weekW.end } }),
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: monthW.start, $lt: monthW.end } }),
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: yearW.start, $lt: yearW.end } })
]);
const t = todayR[0] || {}, w = weekR[0] || {}, m = monthR[0] || {}, y = yearR[0] || {};
res.json({
assignedJobs,
assignedHectares,
sprayedToday: round2(t.sprayed || 0),
flightHoursToday: round2((t.flightTime || 0) / 3600),
operations: {
distanceKm: round2((t.distance || 0) / 1000),
sprayVolumeLiters: round2(t.volume || 0)
},
historical: {
jobs: { year: yJobs, month: mJobs, week: wJobs, day: assignedJobs },
hectares: { year: round2(y.sprayed || 0), month: round2(m.sprayed || 0), week: round2(w.sprayed || 0), day: round2(t.sprayed || 0) },
flightHours: { year: round2((y.flightTime || 0) / 3600), month: round2((m.flightTime || 0) / 3600), week: round2((w.flightTime || 0) / 3600), day: round2((t.flightTime || 0) / 3600) }
}
});
}
// ─── GET /api/dashboard/pilot/summary ────────────────────────────────────────
/**
* @api {get} /api/dashboard/pilot/summary Pilot Daily Summary
* @apiName GetPilotSummary
* @apiGroup PilotDashboard
* @apiDescription Returns today vs yesterday operational metrics with percentage deltas.
*
* @apiQuery {String} [tz=UTC] IANA timezone string.
*/
async function getSummary(req, res) {
if (!req.uid) AppAuthError.throw();
const tz = validateTz(req.query.tz);
const todayW = dayWindow(0, tz);
const yesterW = dayWindow(1, tz);
const jobs = await fetchPilotJobs(req.uid);
const jobIds = jobs.map(j => j._id);
const empty = { hectares: 0, flightHours: 0, haPerHour: 0, avgSpeedKmh: 0, sprayVolumeLiters: 0 };
if (!jobIds.length) {
return res.json({ today: empty, yesterday: empty, deltas: { hectaresPct: null, flightHoursPct: null, haPerHourPct: null, avgSpeedPct: null, sprayVolumePct: null } });
}
const base = appMatch(jobIds);
const [todayR, yesterR] = await Promise.all([
App.aggregate([
{ $match: { ...base, createdDate: { $gte: todayW.start, $lt: todayW.end } } },
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } }
]),
App.aggregate([
{ $match: { ...base, createdDate: { $gte: yesterW.start, $lt: yesterW.end } } },
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } }
])
]);
function buildDay(r) {
const raw = r[0] || {};
const hours = round2((raw.flightTime || 0) / 3600);
const ha = round2(raw.sprayed || 0);
const haPerHour = hours > 0 ? round2(ha / hours) : 0;
return {
hectares: ha,
flightHours: hours,
haPerHour,
avgSpeedKmh: round2((raw.avgSpeed || 0) * 3.6),
sprayVolumeLiters: round2(raw.volume || 0)
};
}
const today = buildDay(todayR);
const yesterday = buildDay(yesterR);
res.json({
today,
yesterday,
deltas: {
hectaresPct: safePct(today.hectares, yesterday.hectares),
flightHoursPct: safePct(today.flightHours, yesterday.flightHours),
haPerHourPct: safePct(today.haPerHour, yesterday.haPerHour),
avgSpeedPct: safePct(today.avgSpeedKmh, yesterday.avgSpeedKmh),
sprayVolumePct: safePct(today.sprayVolumeLiters, yesterday.sprayVolumeLiters)
}
});
}
// ─── GET /api/dashboard/pilot/trend ──────────────────────────────────────────
/**
* @api {get} /api/dashboard/pilot/trend Pilot Trend Charts Data
* @apiName GetPilotTrend
* @apiGroup PilotDashboard
* @apiDescription Returns daily hours flown and hectares sprayed for a date range.
* Defaults to the current calendar week (MonSun). Missing days are filled with 0.
*
* @apiQuery {String} [tz=UTC] IANA timezone string.
* @apiQuery {String} [startDate] YYYY-MM-DD start date (inclusive). Defaults to Monday of current week.
* @apiQuery {String} [endDate] YYYY-MM-DD end date (inclusive). Defaults to Sunday of current week.
*/
async function getTrend(req, res) {
if (!req.uid) AppAuthError.throw();
const tz = validateTz(req.query.tz);
let startDate = req.query.startDate;
let endDate = req.query.endDate;
if (!startDate || !endDate) {
const ww = weekWindow(tz);
startDate = toDateLabel(ww.start, tz);
// ww.end is exclusive (start of next Mon), so subtract 1 ms to get Sun
endDate = toDateLabel(new Date(ww.end.getTime() - 1), tz);
}
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRe.test(startDate) || !dateRe.test(endDate)) AppParamError.throw();
const startUTC = midnightUTC(startDate, tz);
const endExcl = new Date(midnightUTC(endDate, tz).getTime() + 86400000);
const diffDays = Math.round((endExcl - startUTC) / 86400000);
if (diffDays < 1 || diffDays > 90) AppParamError.throw();
const jobs = await fetchPilotJobs(req.uid);
const jobIds = jobs.map(j => j._id);
// Generate all date labels for the range
const labels = [];
const pivot = new Date(`${startDate}T12:00:00.000Z`);
while (true) {
const label = toDateLabel(pivot, tz);
labels.push(label);
if (label === endDate) break;
pivot.setUTCDate(pivot.getUTCDate() + 1);
if (labels.length > 91) break; // safety cap
}
if (!jobIds.length) {
return res.json({ labels, hoursFlown: labels.map(() => 0), hectaresPerDay: labels.map(() => 0) });
}
const grouped = await App.aggregate([
{
$match: {
...appMatch(jobIds),
createdDate: { $gte: startUTC, $lt: endExcl }
}
},
{
$group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdDate', timezone: tz } },
hoursFlown: { $sum: '$totalFlightTime' },
hectaresPerDay: { $sum: '$totalSprayed' }
}
}
]);
const dataMap = {};
for (const r of grouped) {
dataMap[r._id] = { hoursFlown: round2(r.hoursFlown / 3600), hectaresPerDay: round2(r.hectaresPerDay) };
}
const hoursFlown = labels.map(l => (dataMap[l] || {}).hoursFlown || 0);
const hectaresPerDay = labels.map(l => (dataMap[l] || {}).hectaresPerDay || 0);
res.json({ labels, hoursFlown, hectaresPerDay });
}
// ─── GET /api/dashboard/pilot/activeJobs ─────────────────────────────────────
/**
* Display status groupings used by the frontend color/badge system.
* NEW (0) → 'NEW'
* READY/DOWNLOADED/SPRAYED → 'IN_PROGRESS'
* COMPLETED (4) → 'COMPLETED'
*/
function toDisplayStatus(status) {
if (status === JobStatus.NEW) return 'NEW';
if (status === JobStatus.COMPLETED) return 'COMPLETED';
return 'IN_PROGRESS';
}
/**
* @api {get} /api/dashboard/pilot/activeJobs Pilot Active Jobs Panel
* @apiName GetPilotActiveJobs
* @apiGroup PilotDashboard
* @apiDescription Returns the pilot's assigned jobs (active statuses only) with per-job
* sprayed hectares and applied volume aggregated from uploaded Application files.
* INVOICED (5) and ARCHIVED (9) jobs are excluded.
*/
async function getActiveJobs(req, res) {
if (!req.uid) AppAuthError.throw();
const jobs = await Job.aggregate([
{
$match: {
operator: ObjectId(req.uid),
markedDelete: { $ne: true },
status: { $in: ACTIVE_JOB_STATUSES }
}
},
{ $sort: { createdAt: -1 } },
{ $limit: 50 },
// Client name (stored in users collection)
{
$lookup: {
from: 'users',
localField: 'client',
foreignField: '_id',
as: 'clientDoc'
}
},
{ $unwind: { path: '$clientDoc', preserveNullAndEmptyArrays: true } },
// Aircraft registration (Vehicle is a User discriminator stored in users collection)
{
$lookup: {
from: 'users',
localField: 'vehicle',
foreignField: '_id',
as: 'vehicleDoc'
}
},
{ $unwind: { path: '$vehicleDoc', preserveNullAndEmptyArrays: true } },
// Per-job aggregated Application totals
{
$lookup: {
from: 'applications',
let: { jobId: '$_id' },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ['$jobId', '$$jobId'] },
{ $eq: ['$status', 3] },
{ $ne: ['$markedDelete', true] }
]
}
}
},
{
$group: {
_id: null,
haSprayed: { $sum: '$totalSprayed' },
volumeApplied: { $sum: '$totalSprayMat' }
}
}
],
as: 'appTotals'
}
},
{ $unwind: { path: '$appTotals', preserveNullAndEmptyArrays: true } },
{
$project: {
_id: 1,
name: 1,
status: 1,
haTotal: '$ttSprArea',
clientName: '$clientDoc.name',
aircraftReg: { $ifNull: ['$vehicleDoc.tailNumber', '$vehicleDoc.unitId'] },
haSprayed: { $ifNull: ['$appTotals.haSprayed', 0] },
volumeAppliedLiters: { $ifNull: ['$appTotals.volumeApplied', 0] }
}
}
]);
const result = jobs.map(j => {
const haSprayed = round2(j.haSprayed);
const haTotal = round2(j.haTotal || 0);
const progressPct = haTotal > 0 ? Math.min(100, Math.max(0, Math.round((haSprayed / haTotal) * 100))) : 0;
return {
jobId: j._id,
name: j.name || '',
clientName: j.clientName || '',
aircraftReg: j.aircraftReg || '',
status: j.status,
displayStatus: toDisplayStatus(j.status),
haTotal,
haSprayed,
progressPct,
volumeAppliedLiters: round2(j.volumeAppliedLiters)
};
});
res.json({ jobs: result });
}
// ─── GET /api/dashboard/pilot/performance ────────────────────────────────────
/**
* @api {get} /api/dashboard/pilot/performance Pilot Performance Gauges
* @apiName GetPilotPerformance
* @apiGroup PilotDashboard
* @apiDescription Returns average XT cross-track error and spray altitude gauges.
* Aggregates over ApplicationDetail records for the pilot's last 10 processed
* application files. Altitude source priority: sprayHeight (FM) → radarAlt (AGL).
* Returns hasXtData / hasAltitudeData = false when no sensor data exists.
*
* NOTE: ApplicationDetail is a billion-document collection.
* All queries are scoped by fileId to use the fileId index and avoid collection scans.
*/
async function getPerformance(req, res) {
if (!req.uid) AppAuthError.throw();
const jobs = await fetchPilotJobs(req.uid);
const jobIds = jobs.map(j => j._id);
const noData = {
avgXtErrorMeters: null,
xtThreshold: { good: XT_GOOD, monitor: XT_MONITOR },
hasXtData: false,
avgSprayAltitudeMeters: null,
altitudeSource: null,
altThreshold: { target: ALT_TARGET, goodBand: ALT_GOOD, monitorBand: ALT_MONITOR },
hasAltitudeData: false,
sampleSize: 0
};
if (!jobIds.length) return res.json(noData);
// Step 1: Get the 10 most recently uploaded processed Application records
const recentApps = await App.find(appMatch(jobIds)).sort({ createdDate: -1 }).limit(10).select('_id').lean();
if (!recentApps.length) return res.json(noData);
// Step 2: Get all AppFiles for those applications
const appIds = recentApps.map(a => a._id);
const appFiles = await AppFile.find(
{ appId: { $in: appIds }, markedDelete: { $ne: true } },
{ _id: 1 }
).lean();
if (!appFiles.length) return res.json(noData);
const fileIds = appFiles.map(f => f._id);
// Step 3: Aggregate ApplicationDetail — uses fileId index only (no collection scan)
const agg = await AppDetail.aggregate([
{ $match: { fileId: { $in: fileIds } } },
{
$group: {
_id: null,
// XT error: average of abs(xTrack) excluding zero (zero means "no reading")
avgXtError: { $avg: { $cond: [{ $ne: ['$xTrack', 0] }, { $abs: '$xTrack' }, null] } },
xtCount: { $sum: { $cond: [{ $ne: ['$xTrack', 0] }, 1, 0] } },
// sprayHeight: Flight Master dedicated sensor
avgSprayHeight: { $avg: { $cond: [{ $gt: ['$sprayHeight', 0] }, '$sprayHeight', null] } },
sprayHeightCount: { $sum: { $cond: [{ $gt: ['$sprayHeight', 0] }, 1, 0] } },
// radarAlt: fallback AGL source
avgRadarAlt: { $avg: { $cond: [{ $gt: ['$radarAlt', 0] }, '$radarAlt', null] } },
radarAltCount: { $sum: { $cond: [{ $gt: ['$radarAlt', 0] }, 1, 0] } }
}
}
]);
const r = agg[0];
if (!r) return res.json({ ...noData, sampleSize: fileIds.length });
// Altitude: prefer sprayHeight over radarAlt
let avgSprayAltitudeMeters = null;
let altitudeSource = null;
let hasAltitudeData = false;
if (r.sprayHeightCount > 0 && r.avgSprayHeight != null) {
avgSprayAltitudeMeters = round2(r.avgSprayHeight);
altitudeSource = 'sprayHeight';
hasAltitudeData = true;
} else if (r.radarAltCount > 0 && r.avgRadarAlt != null) {
avgSprayAltitudeMeters = round2(r.avgRadarAlt);
altitudeSource = 'radarAlt';
hasAltitudeData = true;
}
const hasXtData = r.xtCount > 0 && r.avgXtError != null;
const avgXtErrorMeters = hasXtData ? round2(r.avgXtError) : null;
res.json({
avgXtErrorMeters,
xtThreshold: { good: XT_GOOD, monitor: XT_MONITOR },
hasXtData,
avgSprayAltitudeMeters,
altitudeSource,
altThreshold: { target: ALT_TARGET, goodBand: ALT_GOOD, monitorBand: ALT_MONITOR },
hasAltitudeData,
sampleSize: fileIds.length
});
}
// ─── PATCH /api/jobs/:job_id/complete ────────────────────────────────────────
/**
* @api {patch} /api/jobs/:job_id/complete Mark Job as Completed
* @apiName CompleteJob
* @apiGroup Jobs
* @apiDescription Transitions a job from SPRAYED (3) to COMPLETED (4).
* Only the Applicator who owns the job (Job.byPuid) may perform this action.
* Returns 409 if the job is not in SPRAYED status. Returns 401 if the caller
* is not the job owner.
*
* @apiParam {Number} job_id The numeric job ID.
*
* @apiSuccess {Object} job The updated job document.
*
* @apiError (409) {Object} error Status is not SPRAYED, or job does not exist.
* @apiError (401) {Object} error Caller is not the job owner.
*/
async function completeJob(req, res) {
const jobId = Number(req.params.job_id);
if (!Number.isFinite(jobId) || jobId <= 0) AppParamError.throw();
const job = await Job.findById(jobId, { status: 1, byPuid: 1 }).lean();
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
// Only the Applicator who owns the job may complete it
if (!job.byPuid || job.byPuid.toString() !== req.uid) AppAuthError.throw();
// Transition is only valid from SPRAYED (3)
if (job.status !== JobStatus.SPRAYED) AppParamError.throw(Errors.STATUS_JOB_INVALID);
const updated = await Job.findByIdAndUpdate(
jobId,
{ $set: { status: JobStatus.COMPLETED } },
{ new: true, lean: true }
)
.populate({ path: 'client', select: 'name' })
.populate({ path: 'operator', select: 'name' })
.populate({ path: 'vehicle', select: 'name tailNumber unitId' });
if (!updated) AppError.throw(Errors.JOB_NOT_FOUND);
debug('Job %d marked COMPLETED by %s', jobId, req.uid);
res.json(updated);
}
module.exports = {
getKpi,
getSummary,
getTrend,
getActiveJobs,
getPerformance,
completeJob
};