{ "info": { "name": "AgMission — Pilot Analytics Dashboard API", "description": "Testing collection for the Pilot Analytics Dashboard endpoints.\n\n## Setup\n1. Set `baseUrl` to your server (e.g. `https://localhost:4100`).\n2. Run **[Auth] Login** — the `jwt` variable is captured automatically.\n3. Set `tz` to your local IANA timezone (e.g. `America/Sao_Paulo`). Defaults to `UTC`.\n4. For the **Complete Job** request, set `jobId` to a job in SPRAYED (3) status owned by the logged-in applicator.\n\n## Folders\n- **[Auth]** — Login to get a JWT\n- **[Dashboard] KPI and Summary** — KPI cards and today/yesterday comparison\n- **[Dashboard] Trend** — Daily trend chart data\n- **[Dashboard] Jobs** — Active jobs panel\n- **[Dashboard] Performance** — XT error and altitude gauges\n- **[Jobs] Complete** — Transition a job from SPRAYED to COMPLETED", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "variable": [ { "key": "baseUrl", "value": "https://localhost:4100", "type": "string", "description": "Server base URL" }, { "key": "jwt", "value": "", "type": "string", "description": "Captured automatically by the Login request" }, { "key": "tz", "value": "UTC", "type": "string", "description": "IANA timezone string e.g. America/Sao_Paulo" }, { "key": "jobId", "value": "", "type": "string", "description": "Numeric Job ID in SPRAYED (3) status — used for the Complete Job request" } ], "item": [ { "name": "[Auth]", "item": [ { "name": "Login (get JWT)", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "const r = pm.response.json();", "if (r && r.token) {", " pm.collectionVariables.set('jwt', r.token);", " console.log('JWT captured');", "} else {", " console.warn('Login response did not contain a token:', JSON.stringify(r));", "}" ] } } ], "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"username\": \"pilot@example.com\",\n \"password\": \"yourpassword\"\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "{{baseUrl}}/api/users/login", "host": ["{{baseUrl}}"], "path": ["api", "users", "login"] }, "description": "Standard AgMission login. The JWT is stored in the `jwt` collection variable and used automatically by all dashboard requests.\n\nLog in as a **Pilot** user to test dashboard read endpoints. Log in as the **Applicator** (byPuid) to test the Complete Job action." } } ] }, { "name": "[Dashboard] KPI and Summary", "description": "KPI cards and today-vs-yesterday summary panel.", "item": [ { "name": "KPI Cards", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 200', () => pm.response.to.have.status(200));", "const r = pm.response.json();", "pm.test('Has assignedJobs', () => pm.expect(r).to.have.property('assignedJobs'));", "pm.test('Has assignedHectares', () => pm.expect(r).to.have.property('assignedHectares'));", "pm.test('Has sprayedToday', () => pm.expect(r).to.have.property('sprayedToday'));", "pm.test('Has flightHoursToday', () => pm.expect(r).to.have.property('flightHoursToday'));", "pm.test('Has operations block', () => pm.expect(r.operations).to.have.keys(['distanceKm','sprayVolumeLiters']));", "pm.test('Has historical block', () => pm.expect(r.historical).to.have.keys(['jobs','hectares','flightHours']));" ] } } ], "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/dashboard/pilot/kpi?tz={{tz}}", "host": ["{{baseUrl}}"], "path": ["api", "dashboard", "pilot", "kpi"], "query": [{ "key": "tz", "value": "{{tz}}", "description": "IANA timezone" }] }, "description": "Returns all KPI card values for the authenticated pilot.\n\n**Expected fields**: `assignedJobs`, `assignedHectares`, `sprayedToday`, `flightHoursToday`, `operations.distanceKm`, `operations.sprayVolumeLiters`, `historical.jobs/hectares/flightHours` × year/month/week/day.\n\nAll numbers are rounded to 2 decimal places." } }, { "name": "Daily Summary (today vs yesterday)", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 200', () => pm.response.to.have.status(200));", "const r = pm.response.json();", "pm.test('Has today', () => pm.expect(r).to.have.property('today'));", "pm.test('Has yesterday', () => pm.expect(r).to.have.property('yesterday'));", "pm.test('Has deltas', () => pm.expect(r).to.have.property('deltas'));", "pm.test('Today has expected keys', () => pm.expect(r.today).to.have.keys(['hectares','flightHours','haPerHour','avgSpeedKmh','sprayVolumeLiters']));", "pm.test('Deltas Pct values are null or number', () => {", " Object.values(r.deltas).forEach(v => {", " pm.expect(v === null || typeof v === 'number').to.be.true;", " });", "});" ] } } ], "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/dashboard/pilot/summary?tz={{tz}}", "host": ["{{baseUrl}}"], "path": ["api", "dashboard", "pilot", "summary"], "query": [{ "key": "tz", "value": "{{tz}}", "description": "IANA timezone" }] }, "description": "Returns today and yesterday operational metrics with percentage change deltas.\n\n`deltas.*Pct` is `null` when yesterday value was 0 (avoids division by zero). Negative = today was worse than yesterday." } } ] }, { "name": "[Dashboard] Trend", "description": "Daily hours and hectares chart data.", "item": [ { "name": "Trend — Current Week (default)", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 200', () => pm.response.to.have.status(200));", "const r = pm.response.json();", "pm.test('Has labels array', () => pm.expect(r.labels).to.be.an('array'));", "pm.test('Has hoursFlown array', () => pm.expect(r.hoursFlown).to.be.an('array'));", "pm.test('Has hectaresPerDay array', () => pm.expect(r.hectaresPerDay).to.be.an('array'));", "pm.test('All arrays same length', () => {", " pm.expect(r.labels.length).to.equal(r.hoursFlown.length);", " pm.expect(r.labels.length).to.equal(r.hectaresPerDay.length);", "});" ] } } ], "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/dashboard/pilot/trend?tz={{tz}}", "host": ["{{baseUrl}}"], "path": ["api", "dashboard", "pilot", "trend"], "query": [{ "key": "tz", "value": "{{tz}}", "description": "IANA timezone" }] }, "description": "Returns daily `hoursFlown` and `hectaresPerDay` for the current calendar week (Mon–Sun). All three arrays are the same length and positionally aligned." } }, { "name": "Trend — Custom Date Range", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 200', () => pm.response.to.have.status(200));", "const r = pm.response.json();", "pm.test('Labels match expected range length', () => {", " pm.expect(r.labels.length).to.equal(14);", "});" ] } } ], "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/dashboard/pilot/trend?tz={{tz}}&startDate=2026-04-16&endDate=2026-04-29", "host": ["{{baseUrl}}"], "path": ["api", "dashboard", "pilot", "trend"], "query": [ { "key": "tz", "value": "{{tz}}", "description": "IANA timezone" }, { "key": "startDate", "value": "2026-04-16", "description": "YYYY-MM-DD inclusive" }, { "key": "endDate", "value": "2026-04-29", "description": "YYYY-MM-DD inclusive" } ] }, "description": "14-day range example. Adjust `startDate`/`endDate` as needed. Maximum range is 90 days — requests exceeding that return HTTP 409." } }, { "name": "Trend — Over 90 Days (expect 409)", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 409 for oversized range', () => pm.response.to.have.status(409));" ] } } ], "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/dashboard/pilot/trend?tz={{tz}}&startDate=2025-01-01&endDate=2026-04-29", "host": ["{{baseUrl}}"], "path": ["api", "dashboard", "pilot", "trend"], "query": [ { "key": "tz", "value": "{{tz}}" }, { "key": "startDate", "value": "2025-01-01" }, { "key": "endDate", "value": "2026-04-29" } ] }, "description": "Validates the 90-day cap. Expects HTTP 409 `invalid_param`." } } ] }, { "name": "[Dashboard] Jobs", "description": "Active jobs panel.", "item": [ { "name": "Active Jobs Panel", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 200', () => pm.response.to.have.status(200));", "const r = pm.response.json();", "pm.test('Has jobs array', () => pm.expect(r.jobs).to.be.an('array'));", "if (r.jobs.length > 0) {", " const j = r.jobs[0];", " pm.test('Job has jobId (number)', () => pm.expect(typeof j.jobId).to.equal('number'));", " pm.test('Job has displayStatus', () => pm.expect(['NEW','IN_PROGRESS','COMPLETED']).to.include(j.displayStatus));", " pm.test('Job progressPct in range 0-100', () => pm.expect(j.progressPct).to.be.within(0, 100));", "}" ] } } ], "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/dashboard/pilot/activeJobs", "host": ["{{baseUrl}}"], "path": ["api", "dashboard", "pilot", "activeJobs"] }, "description": "Returns up to 50 jobs in statuses NEW (0) through COMPLETED (4). INVOICED (5) and ARCHIVED (9) are excluded.\n\nEach job has: `jobId` (Number), `name`, `clientName`, `aircraftReg`, `status`, `displayStatus`, `haTotal`, `haSprayed`, `progressPct`, `volumeAppliedLiters`." } } ] }, { "name": "[Dashboard] Performance", "description": "XT error and altitude performance gauges.", "item": [ { "name": "Performance Gauges", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 200', () => pm.response.to.have.status(200));", "const r = pm.response.json();", "pm.test('Has hasXtData', () => pm.expect(r).to.have.property('hasXtData'));", "pm.test('Has hasAltitudeData', () => pm.expect(r).to.have.property('hasAltitudeData'));", "pm.test('Has xtThreshold', () => pm.expect(r.xtThreshold).to.have.keys(['good','monitor']));", "pm.test('Has altThreshold', () => pm.expect(r.altThreshold).to.have.keys(['target','goodBand','monitorBand']));", "pm.test('Has sampleSize', () => pm.expect(r).to.have.property('sampleSize'));", "if (r.hasXtData) {", " pm.test('avgXtErrorMeters is a number when hasXtData', () => pm.expect(typeof r.avgXtErrorMeters).to.equal('number'));", "}", "if (!r.hasXtData) {", " pm.test('avgXtErrorMeters is null when no XT data', () => pm.expect(r.avgXtErrorMeters).to.be.null);", "}", "if (r.hasAltitudeData) {", " pm.test('altitudeSource is sprayHeight or radarAlt', () => pm.expect(['sprayHeight','radarAlt']).to.include(r.altitudeSource));", "}" ] } } ], "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/dashboard/pilot/performance", "host": ["{{baseUrl}}"], "path": ["api", "dashboard", "pilot", "performance"] }, "description": "Returns XT cross-track error and spray altitude averages based on the pilot's last 10 processed application files.\n\n`hasXtData` / `hasAltitudeData` are `false` when no sensor readings exist — render a 'No data' placeholder in that case.\n\n`altitudeSource` is `sprayHeight` (FM sensor, preferred) or `radarAlt` (AGL fallback)." } } ] }, { "name": "[Jobs] Complete", "description": "Transition a SPRAYED job to COMPLETED. Must be logged in as the Applicator (byPuid) who owns the job.", "item": [ { "name": "Complete Job (happy path)", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 200', () => pm.response.to.have.status(200));", "const r = pm.response.json();", "pm.test('Job status is now 4 (COMPLETED)', () => pm.expect(r.status).to.equal(4));" ] } } ], "request": { "method": "PATCH", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/jobs/{{jobId}}/complete", "host": ["{{baseUrl}}"], "path": ["api", "jobs", "{{jobId}}", "complete"] }, "description": "Transitions job `{{jobId}}` from SPRAYED (3) to COMPLETED (4).\n\nRequirements:\n- Caller must be the Applicator who owns the job (`Job.byPuid === req.uid`).\n- Job must be in SPRAYED (3) status.\n\nSet the `jobId` collection variable to a valid numeric job ID before running." } }, { "name": "Complete Job — not owner (expect 401)", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 401 when not the job owner', () => pm.response.to.have.status(401));", "const r = pm.response.json();", "pm.test('Error tag is not_authorized', () => pm.expect(r.error['.tag']).to.equal('not_authorized'));" ] } } ], "request": { "method": "PATCH", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/jobs/{{jobId}}/complete", "host": ["{{baseUrl}}"], "path": ["api", "jobs", "{{jobId}}", "complete"] }, "description": "Run this request while logged in as a **different** user than the job owner to confirm the 401 guard works. Set `jobId` to a job you do NOT own." } }, { "name": "Complete Job — wrong status (expect 409)", "event": [ { "listen": "test", "script": { "type": "text/javascript", "exec": [ "pm.test('Status is 409 for wrong job status', () => pm.response.to.have.status(409));", "const r = pm.response.json();", "pm.test('Error tag is status_job_invalid', () => pm.expect(r.error['.tag']).to.equal('status_job_invalid'));" ] } } ], "request": { "method": "PATCH", "header": [{ "key": "Authorization", "value": "Bearer {{jwt}}" }], "url": { "raw": "{{baseUrl}}/api/jobs/{{jobId}}/complete", "host": ["{{baseUrl}}"], "path": ["api", "jobs", "{{jobId}}", "complete"] }, "description": "Run this after the happy-path request has already moved the job to COMPLETED (4). The job is no longer in SPRAYED status, so you should receive 409 `status_job_invalid`." } } ] } ] }