From a959860b0b2c0c7b78c47cf8d2a035f74da28f36 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 23 Apr 2025 10:22:51 -0400 Subject: [PATCH 01/59] LF-4765 Add get prescriptions route to routes --- .../api/src/routes/irrigationPrescriptionRequestRoute.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts b/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts index 4cc342c7c0..4f2a3470b3 100644 --- a/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts +++ b/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts @@ -19,6 +19,12 @@ import IrrigationPrescriptionRequestController from '../controllers/irrigationPr const router = express.Router(); +router.get( + '/', + checkScope(['get:sensors']), + IrrigationPrescriptionRequestController.getPrescriptions(), +); + router.post( '/', checkScope(['get:smart_irrigation']), From 3b380707a40e752d4d02c269591dfb96e6361fd8 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 23 Apr 2025 10:26:32 -0400 Subject: [PATCH 02/59] LF-4765 Add get prescriptions wireframe to controller --- .../irrigationPrescriptionRequestController.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts index 83014bd15f..e3206e62ac 100644 --- a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts @@ -33,6 +33,18 @@ export interface LiteFarmRequest extends Request { + const { _farm_id } = req.headers; + try { + // get org id from addon + // send request to esci + // format response + } catch (_error) { + // catch error + } + }; + }, initiateFarmIrrigationPrescription() { return async (req: LiteFarmRequest, res: Response) => { const { farm_id } = req.headers; From 0f9e03e928811957ecd2ff6816c3818b29960f0b Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 23 Apr 2025 20:18:02 -0400 Subject: [PATCH 03/59] LF-4765 WIP - add basic mock and get farmaddon function --- ...irrigationPrescriptionRequestController.ts | 30 ++++++--- packages/api/src/util/ensembleService.ts | 64 +++++++++++++++++++ .../api/src/util/ensembleService.types.ts | 19 ++++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts index e3206e62ac..8926d02b45 100644 --- a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts @@ -14,19 +14,24 @@ */ import { Request, Response } from 'express'; -import { getOrgLocationAndCropData, sendFieldAndCropDataToEsci } from '../util/ensembleService.js'; +import { + getEsciPrescriptions, + getOrgLocationAndCropData, + sendFieldAndCropDataToEsci, +} from '../util/ensembleService.js'; interface HttpError extends Error { status?: number; code?: number; // LF custom error } -interface LiteFarmQuery { +interface InitiateFarmIrrigationPrescriptionQueryParams { allOrgs?: string; shouldSend?: string; } -export interface LiteFarmRequest extends Request { +export interface LiteFarmRequest + extends Request { headers: Request['headers'] & { farm_id?: string; }; @@ -34,19 +39,28 @@ export interface LiteFarmRequest extends Request { - const { _farm_id } = req.headers; + return async (req: LiteFarmRequest, res: Response) => { try { + const { farm_id } = req.headers; + // Middleware checkScope guarantees farm_id but LiteFarmRequest type fails when requiring it + if (!farm_id || farm_id === 'undefined') { + return res.status(400).send('Missing farm_id in headers'); + } + // TODO: should location_id, partner_id be a param? + // get org id from addon - // send request to esci - // format response + const _prescriptions = await getEsciPrescriptions(farm_id); + // return prescriptions } catch (_error) { // catch error } }; }, initiateFarmIrrigationPrescription() { - return async (req: LiteFarmRequest, res: Response) => { + return async ( + req: LiteFarmRequest, + res: Response, + ) => { const { farm_id } = req.headers; const { allOrgs, shouldSend } = req.query; diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index a8fb496905..aac93bf459 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -28,8 +28,72 @@ import type { LocationAndCropGraph, EnsembleLocationAndCropData, ManagementPlan, + FarmAddon, + IrrigationPrescription, } from './ensembleService.types.js'; +/** + * Retrieves the external organisation IDs for a specific farm and partner. + * + * @param farm_id - The ID of the farm to retrieve external organisation IDs for. + * @returns A promise that resolves to the organisation IDs for the given farm and partner. + * @throws Will throw an error if the addon partner or the farm addon is not found. + */ +const getExternalOrganisationIds = async ( + farm_id: string, +): Promise> => { + const partner = await AddonPartnerModel.getPartnerId(ENSEMBLE_BRAND); + if (!partner) { + throw customError(`${ENSEMBLE_BRAND} partner not found`, 404); + } + const farmAddonIds = await FarmAddonModel.getOrganisationIds(farm_id, partner.id); + if (!farmAddonIds) { + throw customError(`Farm not connected to ${ENSEMBLE_BRAND}`, 404); + } + return farmAddonIds; +}; + +/** + * Returns a list of mocked prescriptions based on a specific farm_id. + * + * @param farm_id - The ID of the farm to retrieve mock data for. + * @returns A promise that resolves to formatted irrigation prescription data. + */ +const getMockPrescriptions = async (_farm_id: string): Promise => { + const ONE_HOUR_IN_MS = 1000 * 60 * 60; + const locations = [{ location_id: '87b5a846-fa97-11ef-a688-ce0b8496eaa9' }]; + const tasks = [{ task_id: 15 }]; + + return [ + { + id: 'uuid_maybe_001', + location_id: locations[0].location_id, + recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toDateString(), + partner_id: 1, + task_id: tasks.at(-1)?.task_id, + }, + { + id: 'uuid_maybe_002', + location_id: locations[0].location_id, + recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toDateString(), + partner_id: 1, + task_id: undefined, + }, + ]; +}; + +/** + * Returns a list of mocked prescriptions based on a specific farm_id. + * + * @param farm_id - The ID of the farm to retrieve mock data for. + * @returns A promise that resolves to formatted irrigation prescription data. + */ +export const getEsciPrescriptions = async (farm_id: string): Promise => { + const _externalOrganizationIds = getExternalOrganisationIds(farm_id); + + return await getMockPrescriptions(farm_id); +}; + /** Gathers location and crop data to Ensemble API to initiate irrigation prescriptions * diff --git a/packages/api/src/util/ensembleService.types.ts b/packages/api/src/util/ensembleService.types.ts index ffd01bb3b2..0cb1f6b1ad 100644 --- a/packages/api/src/util/ensembleService.types.ts +++ b/packages/api/src/util/ensembleService.types.ts @@ -83,3 +83,22 @@ export interface EnsembleLocationAndCropData { export interface OrganisationFarmData { [org_uuid: string]: EnsembleLocationAndCropData[]; } + +export type FarmAddon = { + id: number; + farm_id: string; + addon_partner_id: number; + org_uuid: string; + org_pk: number; +}; + +export type ExternalIrrigationPrescription = { + id: number | string; + location_id: number | string; + recommended_start_datetime: string; +}; + +export interface IrrigationPrescription extends ExternalIrrigationPrescription { + partner_id: number; + task_id: number | undefined; +} From 07c4a5bad103167a06b50bc0653a8676b1a0d485 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 25 Apr 2025 09:16:10 -0400 Subject: [PATCH 04/59] LF-4765 send response for postman testing --- .../controllers/irrigationPrescriptionRequestController.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts index 8926d02b45..1261a2ec54 100644 --- a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts @@ -49,10 +49,12 @@ const irrigationPrescriptionRequestController = { // TODO: should location_id, partner_id be a param? // get org id from addon - const _prescriptions = await getEsciPrescriptions(farm_id); + const prescriptions = await getEsciPrescriptions(farm_id); // return prescriptions + res.status(200).send(prescriptions); } catch (_error) { // catch error + res.sendStatus(400); } }; }, From 645fd4dd6f35da4bbe4157dfa441956d737373cc Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 25 Apr 2025 10:27:21 -0400 Subject: [PATCH 05/59] LF-4765 Add blocked model util --- packages/api/src/models/taskModel.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/api/src/models/taskModel.js b/packages/api/src/models/taskModel.js index 538ce6e219..b70bfc429c 100644 --- a/packages/api/src/models/taskModel.js +++ b/packages/api/src/models/taskModel.js @@ -360,16 +360,19 @@ class TaskModel extends BaseModel { */ static async getUnassignedTasksDueThisWeekFromIds(taskIds, isDayLaterThanUTC = false) { const dayLaterInterval = isDayLaterThanUTC ? '"1 day"' : '"0 days"'; - return await TaskModel.query().select('*').whereIn('task_id', taskIds).whereRaw( - ` + return await TaskModel.query() + .select('*') + .whereIn('task_id', taskIds) + .whereRaw( + ` task.assignee_user_id IS NULL AND task.complete_date IS NULL AND task.abandon_date IS NULL AND task.due_date <= (now() + ('1 week')::interval + (?)::interval)::date AND task.due_date >= (now() + (?)::interval)::date `, - [dayLaterInterval, dayLaterInterval], - ); + [dayLaterInterval, dayLaterInterval], + ); } /** @@ -510,6 +513,16 @@ class TaskModel extends BaseModel { .withGraphFetched('[animals(selectId), animal_batches(selectId)]') .whereIn('task_id', taskIds); } + + // TODO: LF-4764 + static async getIrrigationTaskIdByPartnerPrescriptionId(trx, _partner_id, _prescription_id) { + return await TaskModel.query(trx) + .select('task.task_id') + .joinRelated('irrigation_task') + //.where('irrigation_task.partner_id', partner_id) + //.where('irrigation_task.prescription_id', prescription_id) + .whereNotDeleted(); + } } export default TaskModel; From 549fd6c4c94a63b2797f66e73b0498be40db83a1 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 25 Apr 2025 10:30:16 -0400 Subject: [PATCH 06/59] LF-4765 update mocking function --- packages/api/src/util/ensembleService.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index aac93bf459..3dc66150b3 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -31,6 +31,7 @@ import type { FarmAddon, IrrigationPrescription, } from './ensembleService.types.js'; +import TaskModel from '../models/taskModel.js'; /** * Retrieves the external organisation IDs for a specific farm and partner. @@ -59,22 +60,30 @@ const getExternalOrganisationIds = async ( * @param farm_id - The ID of the farm to retrieve mock data for. * @returns A promise that resolves to formatted irrigation prescription data. */ -const getMockPrescriptions = async (_farm_id: string): Promise => { +const getMockPrescriptions = async (farm_id: string): Promise => { const ONE_HOUR_IN_MS = 1000 * 60 * 60; - const locations = [{ location_id: '87b5a846-fa97-11ef-a688-ce0b8496eaa9' }]; - const tasks = [{ task_id: 15 }]; + const locations = await LocationModel.getCropSupportingLocationsByFarmId(farm_id); + // Choose last location + const mockLocation = locations.at(-1); + if (!mockLocation) { + throw customError('No crop locations on farm'); + } + const tasks = await TaskModel.getIrrigationTaskIdByPartnerPrescriptionId(); + if (!tasks) { + throw customError('No irrigation tasks on farm'); + } return [ { id: 'uuid_maybe_001', - location_id: locations[0].location_id, + location_id: mockLocation.location_id, recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toDateString(), partner_id: 1, task_id: tasks.at(-1)?.task_id, }, { id: 'uuid_maybe_002', - location_id: locations[0].location_id, + location_id: locations.at(-1)?.location_id, recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toDateString(), partner_id: 1, task_id: undefined, From 1ed14205133cea4832353914e0d020d6139813e7 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 25 Apr 2025 12:23:52 -0400 Subject: [PATCH 07/59] LF-4765 Was confused about which route I should use moved irrigation prescription to its own route --- .../irrigationPrescriptionController.ts | 44 +++++++++++++++++++ .../irrigationPrescriptionRequestRoute.ts | 6 --- .../src/routes/irrigationPrescriptionRoute.ts | 24 ++++++++++ packages/api/src/server.ts | 2 + packages/api/src/types.ts | 21 +++++++++ 5 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/controllers/irrigationPrescriptionController.ts create mode 100644 packages/api/src/routes/irrigationPrescriptionRoute.ts create mode 100644 packages/api/src/types.ts diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts new file mode 100644 index 0000000000..4848fd0230 --- /dev/null +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Response } from 'express'; +import { LiteFarmRequest, HttpError } from '../types.js'; +import { getEsciPrescriptions } from '../util/ensembleService.js'; + +const irrigationPrescriptionController = { + getPrescriptions() { + return async (req: LiteFarmRequest, res: Response) => { + try { + const { farm_id } = req.headers; + // TODO: should location_id, partner_id be a param? + + // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument + const prescriptions = await getEsciPrescriptions(farm_id); + // return prescriptions + res.status(200).send(prescriptions); + } catch (error: unknown) { + console.error(error); + + const err = error as HttpError; + const status = err.status || err.code || 500; + return res.status(status).json({ + error: err.message || err, + }); + } + }; + }, +}; + +export default irrigationPrescriptionController; diff --git a/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts b/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts index 4f2a3470b3..4cc342c7c0 100644 --- a/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts +++ b/packages/api/src/routes/irrigationPrescriptionRequestRoute.ts @@ -19,12 +19,6 @@ import IrrigationPrescriptionRequestController from '../controllers/irrigationPr const router = express.Router(); -router.get( - '/', - checkScope(['get:sensors']), - IrrigationPrescriptionRequestController.getPrescriptions(), -); - router.post( '/', checkScope(['get:smart_irrigation']), diff --git a/packages/api/src/routes/irrigationPrescriptionRoute.ts b/packages/api/src/routes/irrigationPrescriptionRoute.ts new file mode 100644 index 0000000000..48dcf6e1fe --- /dev/null +++ b/packages/api/src/routes/irrigationPrescriptionRoute.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import express from 'express'; +import checkScope from '../middleware/acl/checkScope.js'; +import IrrigationPrescriptionController from '../controllers/irrigationPrescriptionController.js'; + +const router = express.Router(); + +router.get('/', checkScope(['get:sensors']), IrrigationPrescriptionController.getPrescriptions()); + +export default router; diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index c8550041a6..7d7a6d3973 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -172,6 +172,7 @@ import notificationUserRoute from './routes/notificationUserRoute.js'; import timeNotificationRoute from './routes/timeNotificationRoute.js'; import sensorRoute from './routes/sensorRoute.js'; import farmAddonRoute from './routes/farmAddonRoute.js'; +import irrigationPrescriptionRoute from './routes/irrigationPrescriptionRoute.js'; import irrigationPrescriptionRequestRoute from './routes/irrigationPrescriptionRequestRoute.js'; // register API @@ -341,6 +342,7 @@ app .use('/notification_user', notificationUserRoute) .use('/time_notification', timeNotificationRoute) .use('/farm_addon', farmAddonRoute) + .use('/irrigation_prescription', irrigationPrescriptionRoute) .use('/irrigation_prescription_request', irrigationPrescriptionRequestRoute); // Allow a 1MB limit on sensors to match incoming Ensemble data diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts new file mode 100644 index 0000000000..a1db8e1090 --- /dev/null +++ b/packages/api/src/types.ts @@ -0,0 +1,21 @@ +import { NextFunction, Request, Response } from 'express'; + +export interface HttpError extends Error { + status?: number; + code?: number; // LF custom error +} + +// TODO: Remove farm_id conditional and cast this in a checkScope() that takes the function and casts this to req +export interface LiteFarmRequest + extends Request { + headers: Request['headers'] & { + farm_id?: string; + }; +} + +// Can be used to cast after checkScope() succeeds +export type LiteFarmHandler = ( + req: LiteFarmRequest, + res: Response, + next: NextFunction, +) => void | Promise; From 8ca78c202b14cbdf629c4606d43d1d42624c58d1 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Mon, 28 Apr 2025 14:17:55 -0400 Subject: [PATCH 08/59] LF-4765 Add dayjs package --- packages/api/package-lock.json | 8 +++++--- packages/api/package.json | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/api/package-lock.json b/packages/api/package-lock.json index 40f6552c10..62ad0cda59 100644 --- a/packages/api/package-lock.json +++ b/packages/api/package-lock.json @@ -22,6 +22,7 @@ "core-js": "^3.24.1", "cors": "^2.8.5", "csvtojson": "^2.0.10", + "dayjs": "^1.11.13", "dotenv": "^8.2.0", "email-templates": "^8.0.4", "exceljs": "4.3.0", @@ -8860,9 +8861,10 @@ } }, "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" }, "node_modules/db-errors": { "version": "0.2.3", diff --git a/packages/api/package.json b/packages/api/package.json index b1e0d439d5..f7403f5f34 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -67,6 +67,7 @@ "core-js": "^3.24.1", "cors": "^2.8.5", "csvtojson": "^2.0.10", + "dayjs": "^1.11.13", "dotenv": "^8.2.0", "email-templates": "^8.0.4", "exceljs": "4.3.0", From 89fa6b0f14a0a8fac13b20f4a5af0c1ca4cc98a6 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Mon, 28 Apr 2025 14:23:46 -0400 Subject: [PATCH 09/59] LF-4765 Add model function to return utc_offset --- packages/api/src/models/farmModel.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/api/src/models/farmModel.js b/packages/api/src/models/farmModel.js index 29b73c98a5..1c9ad8e75b 100644 --- a/packages/api/src/models/farmModel.js +++ b/packages/api/src/models/farmModel.js @@ -294,6 +294,17 @@ class Farm extends baseModel { .where('farm_id', farmId) .first(); } + + /** + * Returns a farm and country object by farm id. + * @param {uuid} farmId + * @static + * @async + * @return {Promise<{utc_offset: number}>} + */ + static async getFarmUtcOffset(farmId) { + return Farm.query().select('utc_offset').where('farm_id', farmId).whereNotDeleted().first(); + } } export default Farm; From d489db1e4540f6d228f1806d9843ab1afeebd943 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 29 Apr 2025 11:44:31 -0400 Subject: [PATCH 10/59] LF-4765 Add new farm service and add farm specific date utils --- packages/api/src/util/farmService.ts | 81 ++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/api/src/util/farmService.ts diff --git a/packages/api/src/util/farmService.ts b/packages/api/src/util/farmService.ts new file mode 100644 index 0000000000..3eda93fa66 --- /dev/null +++ b/packages/api/src/util/farmService.ts @@ -0,0 +1,81 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; +import FarmModel from '../models/farmModel.js'; // adjust path if needed + +dayjs.extend(utc); + +/** + * Retrieves the farm's UTC offset (in seconds) from the database and returns the value in minutes. + * + * TODO: Guarantee timezone information on database. + * + * AI-assisted JSDOC + * + * @param farmId The ID of the farm to retrieve the UTC offset for. + * @returns The utc offset in minutes. + */ +async function getFarmUtcOffsetInMinutes(farmId: string): Promise { + const { utc_offset } = await FarmModel.getFarmUtcOffset(farmId); + // If null or undefined, return zero + return utc_offset ? utc_offset / 60 : 0; +} + +/** + * Returns the current date and time in the farm's local time, formatted as an ISO 8601 string. + * + * ** TODO: Use timezone instead of offset as it contains DST data ** + * + * AI-assisted JSDOC + * + * @param farmId The ID of the farm to retrieve the UTC offset for. + * @returns A string representing the current datetime in the farm's local time and correct UTC offset (e.g., "-08:00"). + */ +export async function farmDate(farmId: string): Promise { + // Get current UTC time, then apply offset + return dayjs + .utc() + .utcOffset(await getFarmUtcOffsetInMinutes(farmId)) + .format(); +} + +/** + * Returns the zero-th second of the current date and time in the farm's local time, formatted as an ISO 8601 string. + * + * Use case: External query date ranges, start-date timestamp. + * + * ** TODO: Use timezone instead of offset as it contains DST data ** + * + * AI-assisted JSDOC + * + * @param farmId The ID of the farm to retrieve the UTC offset for. + * @returns A string representing the zero-th second of the current datetime in the farm's local time and correct UTC offset (e.g., "-08:00"). + */ +export async function startOfFarmDate(farmId: string): Promise { + // Get current UTC time, then apply offset + return dayjs + .utc() + .utcOffset(await getFarmUtcOffsetInMinutes(farmId)) + .startOf('day') + .format(); +} + +/** + * Returns the last second of the current date and time in the farm's local time, formatted as an ISO 8601 string. + * + * Use case: External query date ranges, due-date timestamp. + * + * ** TODO: Use timezone instead of offset as it contains DST data ** + * + * AI-assisted JSDOC + * + * @param farmId The ID of the farm to retrieve the UTC offset for. + * @returns A string representing the last second of the current datetime in the farm's local time and correct UTC offset (e.g., "-08:00"). + */ +export async function endOfFarmDate(farmId: string): Promise { + // Get current UTC time, then apply offset + return dayjs + .utc() + .utcOffset(await getFarmUtcOffsetInMinutes(farmId)) + .endOf('day') + .format(); +} From 7149fa7dcdca5ce248bc0eb7527336db5701e6ff Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 29 Apr 2025 12:12:59 -0400 Subject: [PATCH 11/59] LF-4765 Add type guard and change syntax for task_id --- .../api/src/util/ensembleService.types.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/api/src/util/ensembleService.types.ts b/packages/api/src/util/ensembleService.types.ts index 0cb1f6b1ad..16e9a4c076 100644 --- a/packages/api/src/util/ensembleService.types.ts +++ b/packages/api/src/util/ensembleService.types.ts @@ -100,5 +100,26 @@ export type ExternalIrrigationPrescription = { export interface IrrigationPrescription extends ExternalIrrigationPrescription { partner_id: number; - task_id: number | undefined; + task_id?: number | null; +} + +// Type guard for external endpoint +// AI-assisted type guard +export function isIrrigationPrescriptionArray(data: unknown): data is IrrigationPrescription[] { + return ( + Array.isArray(data) && + data.every((item): item is IrrigationPrescription => { + if (typeof item !== 'object' || item === null) return false; + + const obj = item as Record; + + return ( + (typeof obj.id === 'string' || typeof obj.id === 'number') && + (typeof obj.location_id === 'string' || typeof obj.location_id === 'number') && + typeof obj.recommended_start_datetime === 'string' && + typeof obj.partner_id === 'number' && + (typeof obj.task_id === 'number' || obj.task_id === null || obj.task_id === undefined) + ); + }) + ); } From c4ffe7d9d78e3e5d8c363f9b4421ea02604cdd1e Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 29 Apr 2025 12:16:18 -0400 Subject: [PATCH 12/59] LF-4765 Add ensemble call to service and remove and export mocked function --- packages/api/src/util/ensembleService.ts | 47 +++++++++++++++++------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 3dc66150b3..786c354e86 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -23,15 +23,17 @@ import LocationModel from '../models/locationModel.js'; import ManagementPlanModel from '../models/managementPlanModel.js'; import { customError } from './customErrors.js'; import { ENSEMBLE_BRAND, ensembleAPI, ensembleAPICall } from './ensemble.js'; -import type { - OrganisationFarmData, - LocationAndCropGraph, - EnsembleLocationAndCropData, - ManagementPlan, - FarmAddon, - IrrigationPrescription, +import { + type OrganisationFarmData, + type LocationAndCropGraph, + type EnsembleLocationAndCropData, + type ManagementPlan, + type FarmAddon, + type IrrigationPrescription, + isIrrigationPrescriptionArray, } from './ensembleService.types.js'; import TaskModel from '../models/taskModel.js'; +import { startOfFarmDate } from './farmService.js'; /** * Retrieves the external organisation IDs for a specific farm and partner. @@ -60,8 +62,9 @@ const getExternalOrganisationIds = async ( * @param farm_id - The ID of the farm to retrieve mock data for. * @returns A promise that resolves to formatted irrigation prescription data. */ -const getMockPrescriptions = async (farm_id: string): Promise => { +export const getMockPrescriptions = async (farm_id: string): Promise => { const ONE_HOUR_IN_MS = 1000 * 60 * 60; + const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; const locations = await LocationModel.getCropSupportingLocationsByFarmId(farm_id); // Choose last location const mockLocation = locations.at(-1); @@ -77,14 +80,14 @@ const getMockPrescriptions = async (farm_id: string): Promise => { - const _externalOrganizationIds = getExternalOrganisationIds(farm_id); +export const getEsciPrescriptions = async (farmId: string): Promise => { + const externalOrganizationIds = await getExternalOrganisationIds(farmId); + const startOfFarmLocalToday = await startOfFarmDate(farmId); + + // Endpoint config + const axiosObject = { + method: 'get', + url: `${ensembleAPI}/organizations/${externalOrganizationIds.org_pk}/irrigation_prescriptions`, + params: { + after_date: startOfFarmLocalToday, // ISO form or unix instead? + }, + }; + + const onError = (error: AxiosError) => { + const status = error.response?.status || 500; + const errorDetail = error.message ? `: ${error.message}` : ''; + const message = `Error getting irrigation prescriptions${errorDetail}`; + throw customError(message, status); + }; - return await getMockPrescriptions(farm_id); + const data = await ensembleAPICall(axiosObject, onError); + return isIrrigationPrescriptionArray(data) ? data : []; }; /** From 72ac94d2d7ce43039ff86b5414f2ba69665927e8 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 29 Apr 2025 12:18:39 -0400 Subject: [PATCH 13/59] LF-4765 Copy irrigation prescription request fromat for returning mock data --- .../irrigationPrescriptionController.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index 4848fd0230..f8e993a358 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -15,22 +15,31 @@ import { Response } from 'express'; import { LiteFarmRequest, HttpError } from '../types.js'; -import { getEsciPrescriptions } from '../util/ensembleService.js'; +import { getEsciPrescriptions, getMockPrescriptions } from '../util/ensembleService.js'; + +interface DELETEMEQueryParams { + shouldSend?: string; +} const irrigationPrescriptionController = { getPrescriptions() { - return async (req: LiteFarmRequest, res: Response) => { + return async (req: LiteFarmRequest, res: Response) => { try { const { farm_id } = req.headers; - // TODO: should location_id, partner_id be a param? + const { shouldSend } = req.query; - // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - const prescriptions = await getEsciPrescriptions(farm_id); - // return prescriptions - res.status(200).send(prescriptions); + if (shouldSend === 'true') { + // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument + const prescriptions = await getEsciPrescriptions(farm_id); + return res.status(200).send(prescriptions); + } else { + // Return data for dev purposes + QA + // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument + const mockData = await getMockPrescriptions(farm_id); + return res.status(200).send(mockData); + } } catch (error: unknown) { console.error(error); - const err = error as HttpError; const status = err.status || err.code || 500; return res.status(status).json({ From e6dbd576d5723479acdfd3e38b2ee61fb94c5007 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 1 May 2025 13:58:48 -0400 Subject: [PATCH 14/59] LF-4765 Update task model with jsdoc, uncomment external id info now that pr is merged --- packages/api/src/models/taskModel.js | 29 ++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/api/src/models/taskModel.js b/packages/api/src/models/taskModel.js index b70bfc429c..631f91e844 100644 --- a/packages/api/src/models/taskModel.js +++ b/packages/api/src/models/taskModel.js @@ -514,14 +514,31 @@ class TaskModel extends BaseModel { .whereIn('task_id', taskIds); } - // TODO: LF-4764 - static async getIrrigationTaskIdByPartnerPrescriptionId(trx, _partner_id, _prescription_id) { - return await TaskModel.query(trx) + /** + * Returns the first task where not deleted that has the external id match + * + * This assumes only one task can exist with the external id + * + * @param {number} partnerId - the date to search + * @param {number} irrigationPrescriptionExternalId - the user who requested this task assignment + * @static + * @async + * @returns {{task_id: number}} - Object with task id property only. + */ + static async getIrrigationTaskIdByPartnerPrescriptionId( + partnerId, + irrigationPrescriptionExternalId, + ) { + return await TaskModel.query() .select('task.task_id') .joinRelated('irrigation_task') - //.where('irrigation_task.partner_id', partner_id) - //.where('irrigation_task.prescription_id', prescription_id) - .whereNotDeleted(); + .where('irrigation_task.partner_id', partnerId) + .where( + 'irrigation_task.irrigation_prescription_external_id', + irrigationPrescriptionExternalId, + ) + .whereNotDeleted() + .first(); } } From a44f01d06e7844a91e2f7f9ac141dc9fa18edeab Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 1 May 2025 14:06:08 -0400 Subject: [PATCH 15/59] LF-4765 Export point type and update type guard to only test external data --- packages/api/src/util/ensembleService.types.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/api/src/util/ensembleService.types.ts b/packages/api/src/util/ensembleService.types.ts index 16e9a4c076..eaece35f80 100644 --- a/packages/api/src/util/ensembleService.types.ts +++ b/packages/api/src/util/ensembleService.types.ts @@ -13,7 +13,7 @@ * GNU General Public License for more details, see . */ -interface Point { +export interface Point { lat: number; lng: number; } @@ -93,7 +93,7 @@ export type FarmAddon = { }; export type ExternalIrrigationPrescription = { - id: number | string; + id: number; location_id: number | string; recommended_start_datetime: string; }; @@ -105,7 +105,9 @@ export interface IrrigationPrescription extends ExternalIrrigationPrescription { // Type guard for external endpoint // AI-assisted type guard -export function isIrrigationPrescriptionArray(data: unknown): data is IrrigationPrescription[] { +export function isExternalIrrigationPrescriptionArray( + data: unknown, +): data is ExternalIrrigationPrescription[] { return ( Array.isArray(data) && data.every((item): item is IrrigationPrescription => { @@ -116,9 +118,7 @@ export function isIrrigationPrescriptionArray(data: unknown): data is Irrigation return ( (typeof obj.id === 'string' || typeof obj.id === 'number') && (typeof obj.location_id === 'string' || typeof obj.location_id === 'number') && - typeof obj.recommended_start_datetime === 'string' && - typeof obj.partner_id === 'number' && - (typeof obj.task_id === 'number' || obj.task_id === null || obj.task_id === undefined) + typeof obj.recommended_start_datetime === 'string' ); }) ); From 7f62c17478fe2a310dfde3fce2405b09a440aa35 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 1 May 2025 14:07:22 -0400 Subject: [PATCH 16/59] LF-4765 Finally stop cheating, just adding full types --- packages/api/src/models/types.ts | 296 +++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 packages/api/src/models/types.ts diff --git a/packages/api/src/models/types.ts b/packages/api/src/models/types.ts new file mode 100644 index 0000000000..7e9eaacb44 --- /dev/null +++ b/packages/api/src/models/types.ts @@ -0,0 +1,296 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Point } from '../util/ensembleService.types.js'; + +/** + * This file should create and hold types that are identical to the model types. + * + * Add optional ? if type can be null, not based on required status. + * + * Once models can be converted to TS merge with the model file. + * + * TODO: RelationMappings + * + */ + +export type Timestamps = { + created_at: string; + updated_at: string; +}; + +export type UserNotificationSetting = { + alert_weather: boolean; + alert_worker_finish: boolean; + alert_action_after_scouting: boolean; + alert_before_planned_date: boolean; + alert_pest: boolean; +}; + +export enum GENDER { + OTHER = 'OTHER', + PREFER_NOT_TO_SAY = 'PREFER_NOT_TO_SAY', + MALE = 'MALE', + FEMALE = 'FEMALE', +} + +export interface User extends Timestamps { + user_id: string; + first_name: string; + last_name: string; + profile_picture: string; + phone_number?: string; + user_address?: string; + email: string; + sandbox_user: boolean; + notification_setting: UserNotificationSetting; + language_preference: string; + status_id: number; // TODO: user status model does not exist + gender: GENDER; + birth_year: number; + do_not_email: boolean; +} + +interface UserTimeStamps extends Timestamps { + created_by_user_id: User['user_id']; + updated_by_user_id: User['user_id']; +} + +interface BaseProperties extends UserTimeStamps { + deleted: boolean; +} + +export type AddonPartner = { + id: number; + name: string; + access_token: string; + refresh_token: string; + root_url: string; + deactivated: boolean; +}; + +enum FarmUnitSystem { + IMPERIAL = 'imperial', + METRIC = 'metric', +} + +enum FarmDateFormat { + 'MM/DD/YY' = 'MM/DD/YY', + 'DD/MM/YY' = 'DD/MM/YY', + 'YY/MM/DD' = 'YY/MM/DD', +} + +enum FarmCurrencyCode { + AFN = 'AFN', + ALL = 'ALL', + DZD = 'DZD', + USD = 'USD', + EUR = 'EUR', + AOA = 'AOA', + XCD = 'XCD', + ARS = 'ARS', + AMD = 'AMD', + AWG = 'AWG', + AUD = 'AUD', + AZN = 'AZN', + BSD = 'BSD', + BHD = 'BHD', + BDT = 'BDT', + BBD = 'BBD', + BYR = 'BYR', + BZD = 'BZD', + XOF = 'XOF', + BMD = 'BMD', + BTN = 'BTN', + INR = 'INR', + BOB = 'BOB', + BOV = 'BOV', + BAM = 'BAM', + BWP = 'BWP', + NOK = 'NOK', + BRL = 'BRL', + BND = 'BND', + BGN = 'BGN', + BIF = 'BIF', + CVE = 'CVE', + KHR = 'KHR', + XAF = 'XAF', + CAD = 'CAD', + KYD = 'KYD', + CLF = 'CLF', + CLP = 'CLP', + CNY = 'CNY', + COP = 'COP', + COU = 'COU', + KMF = 'KMF', + CDF = 'CDF', + NZD = 'NZD', + CRC = 'CRC', + HRK = 'HRK', + CUC = 'CUC', + CUP = 'CUP', + ANG = 'ANG', + CZK = 'CZK', + DKK = 'DKK', + DJF = 'DJF', + DOP = 'DOP', + EGP = 'EGP', + SVC = 'SVC', + ERN = 'ERN', + ETB = 'ETB', + FKP = 'FKP', + FJD = 'FJD', + XPF = 'XPF', + GMD = 'GMD', + GEL = 'GEL', + GHS = 'GHS', + GIP = 'GIP', + GTQ = 'GTQ', + GBP = 'GBP', + GNF = 'GNF', + GYD = 'GYD', + HTG = 'HTG', + HNL = 'HNL', + HKD = 'HKD', + HUF = 'HUF', + ISK = 'ISK', + IDR = 'IDR', + XDR = 'XDR', + IRR = 'IRR', + IQD = 'IQD', + ILS = 'ILS', + JMD = 'JMD', + JPY = 'JPY', + JOD = 'JOD', + KZT = 'KZT', + KES = 'KES', + KPW = 'KPW', + KRW = 'KRW', + KWD = 'KWD', + KGS = 'KGS', + LAK = 'LAK', + LBP = 'LBP', + LSL = 'LSL', + ZAR = 'ZAR', + LRD = 'LRD', + LYD = 'LYD', + CHF = 'CHF', + MOP = 'MOP', + MKD = 'MKD', + MGA = 'MGA', + MWK = 'MWK', + MYR = 'MYR', + MVR = 'MVR', + MRU = 'MRU', + MUR = 'MUR', + XUA = 'XUA', + MXN = 'MXN', + MXV = 'MXV', + MDL = 'MDL', + MNT = 'MNT', + MAD = 'MAD', + MZN = 'MZN', + MMK = 'MMK', + NAD = 'NAD', + NPR = 'NPR', + NIO = 'NIO', + NGN = 'NGN', + OMR = 'OMR', + PKR = 'PKR', + PAB = 'PAB', + PGK = 'PGK', + PYG = 'PYG', + PEN = 'PEN', + PHP = 'PHP', + PLN = 'PLN', + QAR = 'QAR', + RON = 'RON', + RUB = 'RUB', + RWF = 'RWF', + SHP = 'SHP', + WST = 'WST', + STN = 'STN', + SAR = 'SAR', + RSD = 'RSD', + SCR = 'SCR', + SLL = 'SLL', + SGD = 'SGD', + XSU = 'XSU', + SBD = 'SBD', + SOS = 'SOS', + SSP = 'SSP', + LKR = 'LKR', + SDG = 'SDG', + SRD = 'SRD', + SZL = 'SZL', + SEK = 'SEK', + CHE = 'CHE', + CHW = 'CHW', + SYP = 'SYP', + TWD = 'TWD', + TJS = 'TJS', + TZS = 'TZS', + THB = 'THB', + TOP = 'TOP', + TTD = 'TTD', + TND = 'TND', + TRY = 'TRY', + TMT = 'TMT', + UGX = 'UGX', + UAH = 'UAH', + AED = 'AED', + USN = 'USN', + UYI = 'UYI', + UYU = 'UYU', + UZS = 'UZS', + VUV = 'VUV', + VEF = 'VEF', + VND = 'VND', + YER = 'YER', + ZMW = 'ZMW', + ZWL = 'ZWL', +} + +export type FarmUnit = { + measurement: FarmUnitSystem; + currency: FarmCurrencyCode; + date_format: FarmDateFormat; +}; + +export type Country = { + id: number; + country_name: string; + currency: string; + symbol: string; + iso: string; + unit: string; +}; + +export interface Farm extends BaseProperties { + farm_id: string; + farm_name: string; + address: string; + owner_operated: boolean; + grid_points: Point; + country_id: Country['id']; + farm_phone_number: string; + sandbox_farm: boolean; + units: FarmUnit; + default_initial_location_id?: string; + utc_offset: number; + farm_image_url?: string; + farm_image_thumbnail_url?: string; + // sandbox_bool: string; +} From 498fb9ffb7855e7a2c49f6e23e38fdc6f04b6548 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 1 May 2025 14:14:36 -0400 Subject: [PATCH 17/59] LF-4765 Add task id search to data formatting, refactor some types and utility functions --- packages/api/src/util/ensembleService.ts | 84 ++++++++++++++++++------ 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 786c354e86..0baadd2681 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -30,10 +30,12 @@ import { type ManagementPlan, type FarmAddon, type IrrigationPrescription, - isIrrigationPrescriptionArray, + ExternalIrrigationPrescription, + isExternalIrrigationPrescriptionArray, } from './ensembleService.types.js'; import TaskModel from '../models/taskModel.js'; import { startOfFarmDate } from './farmService.js'; +import { AddonPartner, Farm } from '../models/types.js'; /** * Retrieves the external organisation IDs for a specific farm and partner. @@ -42,14 +44,26 @@ import { startOfFarmDate } from './farmService.js'; * @returns A promise that resolves to the organisation IDs for the given farm and partner. * @throws Will throw an error if the addon partner or the farm addon is not found. */ -const getExternalOrganisationIds = async ( - farm_id: string, -): Promise> => { +const getAddonPartnerId = async (): Promise => { const partner = await AddonPartnerModel.getPartnerId(ENSEMBLE_BRAND); if (!partner) { throw customError(`${ENSEMBLE_BRAND} partner not found`, 404); } - const farmAddonIds = await FarmAddonModel.getOrganisationIds(farm_id, partner.id); + return partner.id; +}; + +/** + * Retrieves the external organisation IDs for a specific farm and partner. + * + * @param farm_id - The ID of the farm to retrieve external organisation IDs for. + * @returns A promise that resolves to the organisation IDs for the given farm and partner. + * @throws Will throw an error if the addon partner or the farm addon is not found. + */ +const getExternalOrganisationIds = async ( + farmId: Farm['farm_id'], + addonPartnerId: AddonPartner['id'], +): Promise> => { + const farmAddonIds = await FarmAddonModel.getOrganisationIds(farmId, addonPartnerId); if (!farmAddonIds) { throw customError(`Farm not connected to ${ENSEMBLE_BRAND}`, 404); } @@ -62,34 +76,44 @@ const getExternalOrganisationIds = async ( * @param farm_id - The ID of the farm to retrieve mock data for. * @returns A promise that resolves to formatted irrigation prescription data. */ -export const getMockPrescriptions = async (farm_id: string): Promise => { +export const getMockPrescriptions = async ( + farmId: Farm['farm_id'], +): Promise => { + const PARTNER_ID = 1; const ONE_HOUR_IN_MS = 1000 * 60 * 60; const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; - const locations = await LocationModel.getCropSupportingLocationsByFarmId(farm_id); + const MOCK_EXTERNAL_PRESCRIPTION_ID1 = 1; + const MOCK_EXTERNAL_PRESCRIPTION_ID2 = 2; + + const locations = await LocationModel.getCropSupportingLocationsByFarmId(farmId); // Choose last location const mockLocation = locations.at(-1); if (!mockLocation) { throw customError('No crop locations on farm'); } - const tasks = await TaskModel.getIrrigationTaskIdByPartnerPrescriptionId(); - if (!tasks) { - throw customError('No irrigation tasks on farm'); - } + const foundTask1 = await TaskModel.getIrrigationTaskIdByPartnerPrescriptionId( + PARTNER_ID, + MOCK_EXTERNAL_PRESCRIPTION_ID1, + ); + const foundTask2 = await TaskModel.getIrrigationTaskIdByPartnerPrescriptionId( + PARTNER_ID, + MOCK_EXTERNAL_PRESCRIPTION_ID2, + ); return [ { - id: 'uuid_maybe_001', + id: MOCK_EXTERNAL_PRESCRIPTION_ID1, location_id: mockLocation.location_id, recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), - partner_id: 1, - task_id: tasks.at(-1)?.task_id, + partner_id: PARTNER_ID, + task_id: foundTask1?.task_id, }, { - id: 'uuid_maybe_002', + id: MOCK_EXTERNAL_PRESCRIPTION_ID2, location_id: locations.at(-1)?.location_id, recommended_start_datetime: new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), - partner_id: 1, - task_id: undefined, + partner_id: PARTNER_ID, + task_id: foundTask2.task_id, }, ]; }; @@ -101,7 +125,8 @@ export const getMockPrescriptions = async (farm_id: string): Promise => { - const externalOrganizationIds = await getExternalOrganisationIds(farmId); + const addonPartnerId = await getAddonPartnerId(); + const externalOrganizationIds = await getExternalOrganisationIds(farmId, addonPartnerId); const startOfFarmLocalToday = await startOfFarmDate(farmId); // Endpoint config @@ -120,8 +145,27 @@ export const getEsciPrescriptions = async (farmId: string): Promise { + const foundTask = TaskModel.getIrrigationTaskIdByPartnerPrescriptionId( + addonPartnerId, + irrigationPrescription.id, + ); + return { ...irrigationPrescription, partner_id: addonPartnerId, task_id: foundTask?.task_id }; + }); + + return irrigationPrescriptions; }; /** From 74671e14000a53bcce6466bf8ae441b5531ae036 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 09:43:02 -0400 Subject: [PATCH 18/59] LF-4765 Filter tasks by farm Id since org_id are not constrained to just one farm --- packages/api/src/models/taskModel.js | 28 ++++++++---------------- packages/api/src/util/ensembleService.ts | 26 +++++++++++----------- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/packages/api/src/models/taskModel.js b/packages/api/src/models/taskModel.js index 631f91e844..7a0570c6f5 100644 --- a/packages/api/src/models/taskModel.js +++ b/packages/api/src/models/taskModel.js @@ -515,30 +515,20 @@ class TaskModel extends BaseModel { } /** - * Returns the first task where not deleted that has the external id match + * Returns farm tasks where not deleted that has an external id * - * This assumes only one task can exist with the external id - * - * @param {number} partnerId - the date to search - * @param {number} irrigationPrescriptionExternalId - the user who requested this task assignment + * @param {string} farmId - the farm requesting irrigation tasks * @static * @async - * @returns {{task_id: number}} - Object with task id property only. + * @returns {{task_id: number, irrigation_prescription_external_id: number}[]} - Object array with task id property only. */ - static async getIrrigationTaskIdByPartnerPrescriptionId( - partnerId, - irrigationPrescriptionExternalId, - ) { + static async getIrrigationTaskIdsWithExternalIdByFarm(farmId) { return await TaskModel.query() - .select('task.task_id') - .joinRelated('irrigation_task') - .where('irrigation_task.partner_id', partnerId) - .where( - 'irrigation_task.irrigation_prescription_external_id', - irrigationPrescriptionExternalId, - ) - .whereNotDeleted() - .first(); + .select('task.task_id', 'irrigation_task.irrigation_prescription_external_id') + .joinRelated('[locations, irrigation_task]') + .where('locations.farm_id', farmId) + .whereNotNull('irrigation_task.irrigation_prescription_external_id') + .whereNotDeleted(); } } diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 0baadd2681..9245eeef2b 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -91,14 +91,8 @@ export const getMockPrescriptions = async ( if (!mockLocation) { throw customError('No crop locations on farm'); } - const foundTask1 = await TaskModel.getIrrigationTaskIdByPartnerPrescriptionId( - PARTNER_ID, - MOCK_EXTERNAL_PRESCRIPTION_ID1, - ); - const foundTask2 = await TaskModel.getIrrigationTaskIdByPartnerPrescriptionId( - PARTNER_ID, - MOCK_EXTERNAL_PRESCRIPTION_ID2, - ); + const irrigationTasksWithExternalId = + await TaskModel.getIrrigationTaskIdsWithExternalIdByFarm(farmId); return [ { @@ -106,14 +100,18 @@ export const getMockPrescriptions = async ( location_id: mockLocation.location_id, recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), partner_id: PARTNER_ID, - task_id: foundTask1?.task_id, + task_id: irrigationTasksWithExternalId.find( + (task) => task.irrigation_prescription_external_id === MOCK_EXTERNAL_PRESCRIPTION_ID1, + )?.task_id, }, { id: MOCK_EXTERNAL_PRESCRIPTION_ID2, location_id: locations.at(-1)?.location_id, recommended_start_datetime: new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), partner_id: PARTNER_ID, - task_id: foundTask2.task_id, + task_id: irrigationTasksWithExternalId.find( + (task) => task.irrigation_prescription_external_id === MOCK_EXTERNAL_PRESCRIPTION_ID2, + )?.task_id, }, ]; }; @@ -157,10 +155,12 @@ export const getEsciPrescriptions = async (farmId: string): Promise { - const foundTask = TaskModel.getIrrigationTaskIdByPartnerPrescriptionId( - addonPartnerId, - irrigationPrescription.id, + const foundTask = irrigationTasksWithExternalId.find( + (task) => task.irrigation_prescription_external_id === irrigationPrescription.id, ); return { ...irrigationPrescription, partner_id: addonPartnerId, task_id: foundTask?.task_id }; }); From e60d30094d6ba55c4506e76464b1fc7920b2ec79 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 10:25:41 -0400 Subject: [PATCH 19/59] LF-4765 update scope check --- packages/api/src/routes/irrigationPrescriptionRoute.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routes/irrigationPrescriptionRoute.ts b/packages/api/src/routes/irrigationPrescriptionRoute.ts index 48dcf6e1fe..3106c3431c 100644 --- a/packages/api/src/routes/irrigationPrescriptionRoute.ts +++ b/packages/api/src/routes/irrigationPrescriptionRoute.ts @@ -19,6 +19,10 @@ import IrrigationPrescriptionController from '../controllers/irrigationPrescript const router = express.Router(); -router.get('/', checkScope(['get:sensors']), IrrigationPrescriptionController.getPrescriptions()); +router.get( + '/', + checkScope(['get:smart_irrigation']), + IrrigationPrescriptionController.getPrescriptions(), +); export default router; From 36bbde87b6471a559704058044b41b83c01960db Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 13:40:19 -0400 Subject: [PATCH 20/59] LF-4765 Fix data type in irrigation types --- packages/api/src/models/irrigationTypesModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/models/irrigationTypesModel.js b/packages/api/src/models/irrigationTypesModel.js index 35132dd4c3..0cc17f6619 100644 --- a/packages/api/src/models/irrigationTypesModel.js +++ b/packages/api/src/models/irrigationTypesModel.js @@ -17,7 +17,7 @@ class IrrigationTypesModel extends BaseModel { type: 'object', required: [''], properties: { - irrigation_type_id: { type: 'string' }, + irrigation_type_id: { type: 'integer' }, irrigation_type_name: { type: 'string' }, farm_id: { type: 'string' }, default_measuring_type: { type: 'string' }, From ce0a96911d88a845ab79f4893f17c5d15081dcf3 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 13:42:34 -0400 Subject: [PATCH 21/59] LF-4765 Add more types to type file --- packages/api/src/models/types.ts | 94 +++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/api/src/models/types.ts b/packages/api/src/models/types.ts index 7e9eaacb44..53001b8dbe 100644 --- a/packages/api/src/models/types.ts +++ b/packages/api/src/models/types.ts @@ -18,11 +18,15 @@ import { Point } from '../util/ensembleService.types.js'; /** * This file should create and hold types that are identical to the model types. * - * Add optional ? if type can be null, not based on required status. + * TODO: RelationMappings -- not optional, a discriminted type (eg IrrigationTask) * - * Once models can be converted to TS merge with the model file. + * How to use: + * - Keep identical to model + * - Add optional ? if type can be null, not based on required status. + * - If you find a type is wrong, please also look to see if the model is correct, run tests + * - If the model is correct, use a utility type instead of adding optionals etc. * - * TODO: RelationMappings + * Once models can be converted to TS merge with the model file. * */ @@ -294,3 +298,87 @@ export interface Farm extends BaseProperties { farm_image_thumbnail_url?: string; // sandbox_bool: string; } + +export interface TaskType extends BaseProperties { + task_type_id: number; + task_name: string; + farm_id: Farm['farm_id']; + task_translation_key: string; +} + +enum AbandonmentReason { + OTHER = 'OTHER', + CROP_FAILURE = 'CROP_FAILURE', + LABOUR_ISSUE = 'LABOUR_ISSUE', + MARKET_PROBLEM = 'MARKET_PROBLEM', + WEATHER = 'WEATHER', + MACHINERY_ISSUE = 'MACHINERY_ISSUE', + SCHEDULING_ISSUE = 'SCHEDULING_ISSUE', + NO_ANIMALS = 'NO_ANIMALS', +} +export interface Task extends BaseProperties { + task_id: number; + task_type_id: TaskType['task_type_id']; + due_date: string; + notes?: string; + completion_notes?: string; + owner_user_id: User['user_id']; + assignee_user_id?: User['user_id']; + coordinates: { type: ['object', 'null'] }; + duration?: number; + wage_at_moment?: number; + happiness?: number; + complete_date?: string; + late_time?: string; + for_review_time?: string; + abandon_date?: string; + abandonment_reason: AbandonmentReason; + other_abandonment_reason?: string; + abandonment_notes?: string; + override_hourly_wage: boolean; + // photo deprecated LF-3471 + photo?: string; + // action_needed deprecated LF-3471 + action_needed: boolean; +} + +export interface Location extends BaseProperties { + location_id: string; + farm_id: Farm['farm_id']; + name: string; + notes: string; +} + +export interface IrrigationType extends BaseProperties { + irrigation_type_id: number; + irrigation_type_name: string; + farm_id: Farm['farm_id']; + default_measuring_type: string; + irrigation_type_translation_key: string; +} + +export type IrrigationTaskDetails = { + task_id: Task['task_id']; + irrigation_type_id: IrrigationType['irrigation_type_id']; + irrigation_type_name: string; + estimated_duration?: number; + estimated_duration_unit?: string; + estimated_flow_rate?: number; + estimated_flow_rate_unit?: string; + location_id?: string; + estimated_water_usage?: number; + estimated_water_usage_unit?: string; + application_depth?: number; + application_depth_unit?: string; + measuring_type: string; + percent_of_location_irrigated?: number; + default_location_flow_rate: boolean; + default_location_application_depth: boolean; + default_irrigation_task_type_location: boolean; + default_irrigation_task_type_measurement: boolean; + irrigation_prescription_external_id?: number; +}; + +export interface IrrigationTask extends Task { + irrigation_task: IrrigationTaskDetails; +} From f82a34a3ce453ed776fbb0c79b9183b699026029 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 13:46:21 -0400 Subject: [PATCH 22/59] LF-4765 Update model function to return all tasks, update ensemble service function --- packages/api/src/models/taskModel.js | 6 +++--- packages/api/src/util/ensembleService.ts | 15 ++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/api/src/models/taskModel.js b/packages/api/src/models/taskModel.js index 7a0570c6f5..4b4ce621b7 100644 --- a/packages/api/src/models/taskModel.js +++ b/packages/api/src/models/taskModel.js @@ -520,11 +520,11 @@ class TaskModel extends BaseModel { * @param {string} farmId - the farm requesting irrigation tasks * @static * @async - * @returns {{task_id: number, irrigation_prescription_external_id: number}[]} - Object array with task id property only. + * @returns {import('./types.js').IrrigationTask[]} - Object array with task id property only. */ - static async getIrrigationTaskIdsWithExternalIdByFarm(farmId) { + static async getIrrigationTasksWithExternalIdByFarm(farmId) { return await TaskModel.query() - .select('task.task_id', 'irrigation_task.irrigation_prescription_external_id') + .select('*') .joinRelated('[locations, irrigation_task]') .where('locations.farm_id', farmId) .whereNotNull('irrigation_task.irrigation_prescription_external_id') diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 9245eeef2b..829c43b938 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -92,7 +92,7 @@ export const getMockPrescriptions = async ( throw customError('No crop locations on farm'); } const irrigationTasksWithExternalId = - await TaskModel.getIrrigationTaskIdsWithExternalIdByFarm(farmId); + await TaskModel.getIrrigationTasksWithExternalIdByFarm(farmId); return [ { @@ -101,7 +101,9 @@ export const getMockPrescriptions = async ( recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), partner_id: PARTNER_ID, task_id: irrigationTasksWithExternalId.find( - (task) => task.irrigation_prescription_external_id === MOCK_EXTERNAL_PRESCRIPTION_ID1, + (task) => + task.irrigation_task.irrigation_prescription_external_id === + MOCK_EXTERNAL_PRESCRIPTION_ID1, )?.task_id, }, { @@ -110,7 +112,9 @@ export const getMockPrescriptions = async ( recommended_start_datetime: new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), partner_id: PARTNER_ID, task_id: irrigationTasksWithExternalId.find( - (task) => task.irrigation_prescription_external_id === MOCK_EXTERNAL_PRESCRIPTION_ID2, + (task) => + task.irrigation_task.irrigation_prescription_external_id === + MOCK_EXTERNAL_PRESCRIPTION_ID2, )?.task_id, }, ]; @@ -156,11 +160,12 @@ export const getEsciPrescriptions = async (farmId: string): Promise { const foundTask = irrigationTasksWithExternalId.find( - (task) => task.irrigation_prescription_external_id === irrigationPrescription.id, + (task) => + task.irrigation_task.irrigation_prescription_external_id === irrigationPrescription.id, ); return { ...irrigationPrescription, partner_id: addonPartnerId, task_id: foundTask?.task_id }; }); From f21d7133f7980b857948fff7b114a435aa8b1278 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 13:52:45 -0400 Subject: [PATCH 23/59] LF-4765 Add tests for get endpoint --- .../api/tests/irrigation_prescription.test.ts | 146 ++++++++++++++++++ packages/api/tests/utils/ensembleUtils.ts | 46 ++++++ packages/api/tests/utils/testDataSetup.ts | 5 +- 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 packages/api/tests/irrigation_prescription.test.ts diff --git a/packages/api/tests/irrigation_prescription.test.ts b/packages/api/tests/irrigation_prescription.test.ts new file mode 100644 index 0000000000..aef050ee88 --- /dev/null +++ b/packages/api/tests/irrigation_prescription.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import chai from 'chai'; + +import chaiHttp from 'chai-http'; +chai.use(chaiHttp); +import { Response } from 'superagent'; + +import server from '../src/server.js'; +import knex from '../src/util/knex.js'; +import { tableCleanup } from './testEnvironment.js'; + +jest.mock('jsdom'); +jest.mock('../src/middleware/acl/checkJwt.js', () => + jest.fn((req, _res, next) => { + req.auth = {}; + req.auth.user_id = req.get('user_id'); + next(); + }), +); + +import axios from 'axios'; +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +import { setupFarmEnvironment } from './utils/testDataSetup.js'; +import { connectFarmToEnsemble, fakeIrrigationPrescriptions } from './utils/ensembleUtils.js'; +import type { Farm, IrrigationTask, User } from '../src/models/types.js'; +import mocks from './mock.factories.js'; + +describe('Get Irrigation Prescription Tests', () => { + async function postIrrigationTask({ + farm_id, + user_id, + data, + }: { + farm_id: Farm['farm_id']; + user_id: User['user_id']; + data: Partial; + }) { + return chai + .request(server) + .post(`/task/irrigation_task`) + .set('user_id', user_id) + .set('farm_id', farm_id) + .send(data); + } + + async function getIrrigationPrescription({ + farm_id, + user_id, + shouldSend = 'true', + }: { + farm_id: Farm['farm_id']; + user_id: User['user_id']; + shouldSend?: string; + }): Promise { + return chai + .request(server) + .get('/irrigation_prescription') + .set('content-type', 'application/json') + .set('farm_id', farm_id) + .set('user_id', user_id) + .query({ shouldSend }); + } + + beforeEach(async () => { + (mockedAxios as unknown as jest.Mock).mockClear(); + await mocks.populateTaskTypes(); + }); + + afterEach(async () => { + await tableCleanup(knex); + }); + + afterAll(async () => { + await knex.destroy(); + }); + + describe('All users should be able to GET irrigation prescription', () => { + [1, 2, 3, 5].forEach((role) => { + test(`User with role ${role} should request IPs`, async () => { + const MOCK_EXTERNAL_PRESCRIPTION_ID1 = 1; + const MOCK_EXTERNAL_PRESCRIPTION_ID2 = 2; + const [taskTypeInDb] = await knex('task_type').where({ + farm_id: null, + task_translation_key: 'IRRIGATION_TASK', + }); + const { farm, field, user } = await setupFarmEnvironment(role); + + // Make one task for prescription 1 + await postIrrigationTask({ + farm_id: farm.farm_id, + user_id: user.user_id, + data: mocks.fakeTask({ + task_type_id: taskTypeInDb.task_type_id, + irrigation_task: mocks.fakeIrrigationTask({ + location_id: field.location_id, + irrigation_prescription_external_id: MOCK_EXTERNAL_PRESCRIPTION_ID1, + }), + }), + }); + + const { farmAddon } = await connectFarmToEnsemble(farm); + (mockedAxios as unknown as jest.Mock).mockResolvedValue({ + data: fakeIrrigationPrescriptions( + farm.farm_id, + [MOCK_EXTERNAL_PRESCRIPTION_ID1, MOCK_EXTERNAL_PRESCRIPTION_ID2], + field.location_id, + ), + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }); + + await getIrrigationPrescription({ + farm_id: farm.farm_id, + user_id: user.user_id, + }); + + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + url: expect.stringContaining( + `/organizations/${farmAddon.org_pk}/irrigation_prescriptions`, // real URL here + ), + }), + ); + }); + }); + }); +}); diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index fdbe399674..3abb5efc9a 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -15,6 +15,9 @@ import mocks from '../mock.factories.js'; import { ENSEMBLE_BRAND } from '../../src/util/ensemble.js'; +import { IrrigationPrescription } from '../../src/util/ensembleService.types.js'; +import TaskModel from '../../src/models/taskModel.js'; +import { Location } from '../../src/models/types.js'; export interface Farm { farm_id: string; @@ -28,3 +31,46 @@ export const connectFarmToEnsemble = async (farm: Farm) => { return { farmAddon }; }; + +/** + * Returns a list of mocked prescriptions based on a specific farm_id. + * + * @param farm_id - The ID of the farm to retrieve mock data for. + * @returns A promise that resolves to formatted irrigation prescription data. + */ +export const fakeIrrigationPrescriptions = async ( + farmId: Farm['farm_id'], + prescriptionIds: IrrigationPrescription['id'][] = [1, 2], + locationId: Location['location_id'], +): Promise => { + const PARTNER_ID = 1; + const ONE_HOUR_IN_MS = 1000 * 60 * 60; + const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; + + const irrigationTasksWithExternalId = + await TaskModel.getIrrigationTasksWithExternalIdByFarm(farmId); + + const irrigationTask1 = irrigationTasksWithExternalId.find( + (task) => task.irrigation_task.irrigation_prescription_external_id === prescriptionIds[0], + ); + const irrigationTask2 = irrigationTasksWithExternalId.find( + (task) => task.irrigation_task.irrigation_prescription_external_id === prescriptionIds[1], + ); + + return [ + { + id: prescriptionIds[0], + location_id: irrigationTask1?.irrigation_task.location_id ?? locationId, + recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), + partner_id: PARTNER_ID, + task_id: irrigationTask1?.task_id, + }, + { + id: prescriptionIds[1], + location_id: irrigationTask2?.irrigation_task.location_id ?? locationId, + recommended_start_datetime: new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), + partner_id: PARTNER_ID, + task_id: irrigationTask1?.task_id, + }, + ]; +}; diff --git a/packages/api/tests/utils/testDataSetup.ts b/packages/api/tests/utils/testDataSetup.ts index 9b57d22bbb..f9abfb39cc 100644 --- a/packages/api/tests/utils/testDataSetup.ts +++ b/packages/api/tests/utils/testDataSetup.ts @@ -56,7 +56,10 @@ export async function returnUserFarms(role: number) { * Sets up the farm environment by creating a farm, owner, field, and (optionally) a non-owner user (if role id is provided) */ export async function setupFarmEnvironment(role: number = 1) { - const { mainFarm: farm, user: owner } = await returnUserFarms(1); + const { mainFarm: farm, user: owner } = (await returnUserFarms(1)) as { + mainFarm: Farm; + user: User; + }; let user = owner; if (role !== 1) { From d9c65328f8f5331dc8cea0c2c909cd8755eb6aa2 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 15:07:53 -0400 Subject: [PATCH 24/59] LF-4765 Add after date to request query, remove farmDate utilities, and filter returned irrigation tasks by result of that query --- packages/api/package-lock.json | 1 - packages/api/package.json | 1 - .../irrigationPrescriptionController.ts | 7 +- packages/api/src/models/taskModel.js | 10 ++- packages/api/src/util/ensembleService.ts | 30 ++++--- packages/api/src/util/farmService.ts | 81 ------------------- packages/api/tests/utils/ensembleUtils.ts | 6 +- 7 files changed, 34 insertions(+), 102 deletions(-) delete mode 100644 packages/api/src/util/farmService.ts diff --git a/packages/api/package-lock.json b/packages/api/package-lock.json index 62ad0cda59..e4a2f935af 100644 --- a/packages/api/package-lock.json +++ b/packages/api/package-lock.json @@ -22,7 +22,6 @@ "core-js": "^3.24.1", "cors": "^2.8.5", "csvtojson": "^2.0.10", - "dayjs": "^1.11.13", "dotenv": "^8.2.0", "email-templates": "^8.0.4", "exceljs": "4.3.0", diff --git a/packages/api/package.json b/packages/api/package.json index f7403f5f34..b1e0d439d5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -67,7 +67,6 @@ "core-js": "^3.24.1", "cors": "^2.8.5", "csvtojson": "^2.0.10", - "dayjs": "^1.11.13", "dotenv": "^8.2.0", "email-templates": "^8.0.4", "exceljs": "4.3.0", diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index f8e993a358..26b4af1216 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -18,6 +18,7 @@ import { LiteFarmRequest, HttpError } from '../types.js'; import { getEsciPrescriptions, getMockPrescriptions } from '../util/ensembleService.js'; interface DELETEMEQueryParams { + after_date?: string; shouldSend?: string; } @@ -26,16 +27,16 @@ const irrigationPrescriptionController = { return async (req: LiteFarmRequest, res: Response) => { try { const { farm_id } = req.headers; - const { shouldSend } = req.query; + const { after_date, shouldSend } = req.query; if (shouldSend === 'true') { // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - const prescriptions = await getEsciPrescriptions(farm_id); + const prescriptions = await getEsciPrescriptions(farm_id, after_date); return res.status(200).send(prescriptions); } else { // Return data for dev purposes + QA // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - const mockData = await getMockPrescriptions(farm_id); + const mockData = await getMockPrescriptions(farm_id, after_date); return res.status(200).send(mockData); } } catch (error: unknown) { diff --git a/packages/api/src/models/taskModel.js b/packages/api/src/models/taskModel.js index 4b4ce621b7..99f4827d19 100644 --- a/packages/api/src/models/taskModel.js +++ b/packages/api/src/models/taskModel.js @@ -518,16 +518,18 @@ class TaskModel extends BaseModel { * Returns farm tasks where not deleted that has an external id * * @param {string} farmId - the farm requesting irrigation tasks + * @param {number[]} externalIds - the farm requesting irrigation tasks * @static * @async * @returns {import('./types.js').IrrigationTask[]} - Object array with task id property only. */ - static async getIrrigationTasksWithExternalIdByFarm(farmId) { + static async getIrrigationTasksWithExternalIdByFarm(farmId, externalIds) { return await TaskModel.query() - .select('*') - .joinRelated('[locations, irrigation_task]') - .where('locations.farm_id', farmId) + .select('task.*') + .withGraphJoined('[locations, irrigation_task]') .whereNotNull('irrigation_task.irrigation_prescription_external_id') + .whereIn('irrigation_task.irrigation_prescription_external_id', externalIds) + .where('locations.farm_id', farmId) .whereNotDeleted(); } } diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 829c43b938..e077d540a3 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -34,7 +34,6 @@ import { isExternalIrrigationPrescriptionArray, } from './ensembleService.types.js'; import TaskModel from '../models/taskModel.js'; -import { startOfFarmDate } from './farmService.js'; import { AddonPartner, Farm } from '../models/types.js'; /** @@ -78,6 +77,7 @@ const getExternalOrganisationIds = async ( */ export const getMockPrescriptions = async ( farmId: Farm['farm_id'], + afterDate?: string, ): Promise => { const PARTNER_ID = 1; const ONE_HOUR_IN_MS = 1000 * 60 * 60; @@ -91,14 +91,18 @@ export const getMockPrescriptions = async ( if (!mockLocation) { throw customError('No crop locations on farm'); } - const irrigationTasksWithExternalId = - await TaskModel.getIrrigationTasksWithExternalIdByFarm(farmId); + const irrigationTasksWithExternalId = await TaskModel.getIrrigationTasksWithExternalIdByFarm( + farmId, + [MOCK_EXTERNAL_PRESCRIPTION_ID1, MOCK_EXTERNAL_PRESCRIPTION_ID2], + ); return [ { id: MOCK_EXTERNAL_PRESCRIPTION_ID1, location_id: mockLocation.location_id, - recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), + recommended_start_datetime: afterDate + ? new Date(afterDate).toISOString() + : new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), partner_id: PARTNER_ID, task_id: irrigationTasksWithExternalId.find( (task) => @@ -109,7 +113,9 @@ export const getMockPrescriptions = async ( { id: MOCK_EXTERNAL_PRESCRIPTION_ID2, location_id: locations.at(-1)?.location_id, - recommended_start_datetime: new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), + recommended_start_datetime: afterDate + ? new Date(new Date(afterDate).getTime() + ONE_DAY_IN_MS).toISOString() + : new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), partner_id: PARTNER_ID, task_id: irrigationTasksWithExternalId.find( (task) => @@ -126,17 +132,19 @@ export const getMockPrescriptions = async ( * @param farm_id - The ID of the farm to retrieve mock data for. * @returns A promise that resolves to formatted irrigation prescription data. */ -export const getEsciPrescriptions = async (farmId: string): Promise => { +export const getEsciPrescriptions = async ( + farmId: string, + afterDate?: string, +): Promise => { const addonPartnerId = await getAddonPartnerId(); const externalOrganizationIds = await getExternalOrganisationIds(farmId, addonPartnerId); - const startOfFarmLocalToday = await startOfFarmDate(farmId); // Endpoint config const axiosObject = { method: 'get', url: `${ensembleAPI}/organizations/${externalOrganizationIds.org_pk}/irrigation_prescriptions`, params: { - after_date: startOfFarmLocalToday, // ISO form or unix instead? + after_date: afterDate, // ISO form or unix instead? }, }; @@ -159,8 +167,10 @@ export const getEsciPrescriptions = async (farmId: string): Promise id), + ); const irrigationPrescriptions: IrrigationPrescription[] = data.map((irrigationPrescription) => { const foundTask = irrigationTasksWithExternalId.find( diff --git a/packages/api/src/util/farmService.ts b/packages/api/src/util/farmService.ts deleted file mode 100644 index 3eda93fa66..0000000000 --- a/packages/api/src/util/farmService.ts +++ /dev/null @@ -1,81 +0,0 @@ -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc.js'; -import FarmModel from '../models/farmModel.js'; // adjust path if needed - -dayjs.extend(utc); - -/** - * Retrieves the farm's UTC offset (in seconds) from the database and returns the value in minutes. - * - * TODO: Guarantee timezone information on database. - * - * AI-assisted JSDOC - * - * @param farmId The ID of the farm to retrieve the UTC offset for. - * @returns The utc offset in minutes. - */ -async function getFarmUtcOffsetInMinutes(farmId: string): Promise { - const { utc_offset } = await FarmModel.getFarmUtcOffset(farmId); - // If null or undefined, return zero - return utc_offset ? utc_offset / 60 : 0; -} - -/** - * Returns the current date and time in the farm's local time, formatted as an ISO 8601 string. - * - * ** TODO: Use timezone instead of offset as it contains DST data ** - * - * AI-assisted JSDOC - * - * @param farmId The ID of the farm to retrieve the UTC offset for. - * @returns A string representing the current datetime in the farm's local time and correct UTC offset (e.g., "-08:00"). - */ -export async function farmDate(farmId: string): Promise { - // Get current UTC time, then apply offset - return dayjs - .utc() - .utcOffset(await getFarmUtcOffsetInMinutes(farmId)) - .format(); -} - -/** - * Returns the zero-th second of the current date and time in the farm's local time, formatted as an ISO 8601 string. - * - * Use case: External query date ranges, start-date timestamp. - * - * ** TODO: Use timezone instead of offset as it contains DST data ** - * - * AI-assisted JSDOC - * - * @param farmId The ID of the farm to retrieve the UTC offset for. - * @returns A string representing the zero-th second of the current datetime in the farm's local time and correct UTC offset (e.g., "-08:00"). - */ -export async function startOfFarmDate(farmId: string): Promise { - // Get current UTC time, then apply offset - return dayjs - .utc() - .utcOffset(await getFarmUtcOffsetInMinutes(farmId)) - .startOf('day') - .format(); -} - -/** - * Returns the last second of the current date and time in the farm's local time, formatted as an ISO 8601 string. - * - * Use case: External query date ranges, due-date timestamp. - * - * ** TODO: Use timezone instead of offset as it contains DST data ** - * - * AI-assisted JSDOC - * - * @param farmId The ID of the farm to retrieve the UTC offset for. - * @returns A string representing the last second of the current datetime in the farm's local time and correct UTC offset (e.g., "-08:00"). - */ -export async function endOfFarmDate(farmId: string): Promise { - // Get current UTC time, then apply offset - return dayjs - .utc() - .utcOffset(await getFarmUtcOffsetInMinutes(farmId)) - .endOf('day') - .format(); -} diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index 3abb5efc9a..c6aef2d6b2 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -47,8 +47,10 @@ export const fakeIrrigationPrescriptions = async ( const ONE_HOUR_IN_MS = 1000 * 60 * 60; const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; - const irrigationTasksWithExternalId = - await TaskModel.getIrrigationTasksWithExternalIdByFarm(farmId); + const irrigationTasksWithExternalId = await TaskModel.getIrrigationTasksWithExternalIdByFarm( + farmId, + prescriptionIds, + ); const irrigationTask1 = irrigationTasksWithExternalId.find( (task) => task.irrigation_task.irrigation_prescription_external_id === prescriptionIds[0], From 335ba3987a830744eaf627853a024988f40279d5 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 15:11:25 -0400 Subject: [PATCH 25/59] LF-4765 Add after date to tests --- packages/api/tests/irrigation_prescription.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/api/tests/irrigation_prescription.test.ts b/packages/api/tests/irrigation_prescription.test.ts index aef050ee88..bae8ba0d84 100644 --- a/packages/api/tests/irrigation_prescription.test.ts +++ b/packages/api/tests/irrigation_prescription.test.ts @@ -62,10 +62,12 @@ describe('Get Irrigation Prescription Tests', () => { async function getIrrigationPrescription({ farm_id, user_id, + afterDate = new Date().toISOString(), shouldSend = 'true', }: { farm_id: Farm['farm_id']; user_id: User['user_id']; + afterDate?: string; shouldSend?: string; }): Promise { return chai @@ -74,7 +76,7 @@ describe('Get Irrigation Prescription Tests', () => { .set('content-type', 'application/json') .set('farm_id', farm_id) .set('user_id', user_id) - .query({ shouldSend }); + .query({ afterDate, shouldSend }); } beforeEach(async () => { From c62de99a06924d20fd24688f71cf03b029201cc6 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 2 May 2025 15:21:25 -0400 Subject: [PATCH 26/59] LF-4765 Cleanup duplicated types --- packages/api/tests/sensor.test.ts | 13 +++---------- packages/api/tests/utils/ensembleUtils.ts | 6 +----- packages/api/tests/utils/testDataSetup.ts | 15 ++------------- 3 files changed, 6 insertions(+), 28 deletions(-) diff --git a/packages/api/tests/sensor.test.ts b/packages/api/tests/sensor.test.ts index e4a17523d4..8dae03f1bb 100644 --- a/packages/api/tests/sensor.test.ts +++ b/packages/api/tests/sensor.test.ts @@ -40,14 +40,7 @@ import { returnUserFarms } from './utils/testDataSetup.js'; import { Response } from 'superagent'; import { connectFarmToEnsemble } from './utils/ensembleUtils.js'; import { mockedFormattedReadingsData, mockedEnsembleReadingsData } from './utils/sensorMockData.js'; - -interface User { - user_id: string; -} - -interface Farm { - farm_id: string; -} +import { Farm, User } from '../src/models/types.js'; describe('Sensor Tests', () => { let farm: Farm; @@ -61,8 +54,8 @@ describe('Sensor Tests', () => { endTime, truncPeriod, }: { - user_id: string; - farm_id: string; + user_id: User['user_id']; + farm_id: Farm['farm_id']; esids: string; startTime?: string; endTime?: string; diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index c6aef2d6b2..8315bada9a 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -17,11 +17,7 @@ import mocks from '../mock.factories.js'; import { ENSEMBLE_BRAND } from '../../src/util/ensemble.js'; import { IrrigationPrescription } from '../../src/util/ensembleService.types.js'; import TaskModel from '../../src/models/taskModel.js'; -import { Location } from '../../src/models/types.js'; - -export interface Farm { - farm_id: string; -} +import { Farm, Location } from '../../src/models/types.js'; export const connectFarmToEnsemble = async (farm: Farm) => { const [farmAddon] = await mocks.farm_addonFactory({ diff --git a/packages/api/tests/utils/testDataSetup.ts b/packages/api/tests/utils/testDataSetup.ts index f9abfb39cc..94e4ca4e25 100644 --- a/packages/api/tests/utils/testDataSetup.ts +++ b/packages/api/tests/utils/testDataSetup.ts @@ -15,19 +15,8 @@ import mocks from '../mock.factories.js'; import LocationModel from '../../src/models/locationModel.js'; +import { Farm, Location, User } from '../../src/models/types.js'; -export interface User { - user_id: string; -} - -export interface Farm { - farm_id: string; -} - -export interface FarmEnvironment { - farm: Farm; - field: Record; -} /** * Generates a fake user farm object with the specified role. */ @@ -94,7 +83,7 @@ export async function setupFarmEnvironment(role: number = 1) { /** * Sets up two crop management plans (one seed and one transplant) for the provided farm environment. */ -export async function setupManagementPlans({ farm, field }: FarmEnvironment) { +export async function setupManagementPlans({ farm, field }: { farm: Farm; field: Location }) { const [crop] = await mocks.cropFactory( { promisedFarm: Promise.resolve([farm]) }, { From 1cb661dff48dbdac3205d122343c6bdb8f4c2607 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 6 May 2025 13:30:40 -0400 Subject: [PATCH 27/59] LF-4765 Remove mistakenly added function --- ...irrigationPrescriptionRequestController.ts | 36 ++----------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts index 1261a2ec54..4cf4fd859a 100644 --- a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts @@ -13,12 +13,9 @@ * GNU General Public License for more details, see . */ -import { Request, Response } from 'express'; -import { - getEsciPrescriptions, - getOrgLocationAndCropData, - sendFieldAndCropDataToEsci, -} from '../util/ensembleService.js'; +import { Response } from 'express'; +import { getOrgLocationAndCropData, sendFieldAndCropDataToEsci } from '../util/ensembleService.js'; +import { LiteFarmRequest } from '../types.js'; interface HttpError extends Error { status?: number; @@ -30,34 +27,7 @@ interface InitiateFarmIrrigationPrescriptionQueryParams { shouldSend?: string; } -export interface LiteFarmRequest - extends Request { - headers: Request['headers'] & { - farm_id?: string; - }; -} - const irrigationPrescriptionRequestController = { - getPrescriptions() { - return async (req: LiteFarmRequest, res: Response) => { - try { - const { farm_id } = req.headers; - // Middleware checkScope guarantees farm_id but LiteFarmRequest type fails when requiring it - if (!farm_id || farm_id === 'undefined') { - return res.status(400).send('Missing farm_id in headers'); - } - // TODO: should location_id, partner_id be a param? - - // get org id from addon - const prescriptions = await getEsciPrescriptions(farm_id); - // return prescriptions - res.status(200).send(prescriptions); - } catch (_error) { - // catch error - res.sendStatus(400); - } - }; - }, initiateFarmIrrigationPrescription() { return async ( req: LiteFarmRequest, From 76e4fbc0d7423f2eb1ad7d11ffce85a0b7223309 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 6 May 2025 13:32:27 -0400 Subject: [PATCH 28/59] LF-4765 Farm date cleanup --- packages/api/src/models/farmModel.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/api/src/models/farmModel.js b/packages/api/src/models/farmModel.js index 1c9ad8e75b..29b73c98a5 100644 --- a/packages/api/src/models/farmModel.js +++ b/packages/api/src/models/farmModel.js @@ -294,17 +294,6 @@ class Farm extends baseModel { .where('farm_id', farmId) .first(); } - - /** - * Returns a farm and country object by farm id. - * @param {uuid} farmId - * @static - * @async - * @return {Promise<{utc_offset: number}>} - */ - static async getFarmUtcOffset(farmId) { - return Farm.query().select('utc_offset').where('farm_id', farmId).whereNotDeleted().first(); - } } export default Farm; From 2e90e3a0c238ee528c0c995dfd03ce9fb25c2a05 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 6 May 2025 15:36:19 -0400 Subject: [PATCH 29/59] LF-4765 Remove extra mocking function and move to tests, add todo for after mock no longer needed on endpoint --- .../irrigationPrescriptionController.ts | 12 +++- packages/api/src/util/ensembleService.ts | 57 ------------------- .../api/tests/irrigation_prescription.test.ts | 15 +++-- packages/api/tests/utils/ensembleUtils.ts | 21 +++++-- 4 files changed, 35 insertions(+), 70 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index 26b4af1216..648f8db210 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -15,7 +15,8 @@ import { Response } from 'express'; import { LiteFarmRequest, HttpError } from '../types.js'; -import { getEsciPrescriptions, getMockPrescriptions } from '../util/ensembleService.js'; +import { getEsciPrescriptions } from '../util/ensembleService.js'; +import { fakeIrrigationPrescriptions } from '../../tests/utils/ensembleUtils.js'; interface DELETEMEQueryParams { after_date?: string; @@ -35,8 +36,13 @@ const irrigationPrescriptionController = { return res.status(200).send(prescriptions); } else { // Return data for dev purposes + QA - // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - const mockData = await getMockPrescriptions(farm_id, after_date); + const mockData = await fakeIrrigationPrescriptions( + // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument + farm_id, + [1, 2], + undefined, + after_date, + ); return res.status(200).send(mockData); } } catch (error: unknown) { diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index e077d540a3..8f01117800 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -69,63 +69,6 @@ const getExternalOrganisationIds = async ( return farmAddonIds; }; -/** - * Returns a list of mocked prescriptions based on a specific farm_id. - * - * @param farm_id - The ID of the farm to retrieve mock data for. - * @returns A promise that resolves to formatted irrigation prescription data. - */ -export const getMockPrescriptions = async ( - farmId: Farm['farm_id'], - afterDate?: string, -): Promise => { - const PARTNER_ID = 1; - const ONE_HOUR_IN_MS = 1000 * 60 * 60; - const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; - const MOCK_EXTERNAL_PRESCRIPTION_ID1 = 1; - const MOCK_EXTERNAL_PRESCRIPTION_ID2 = 2; - - const locations = await LocationModel.getCropSupportingLocationsByFarmId(farmId); - // Choose last location - const mockLocation = locations.at(-1); - if (!mockLocation) { - throw customError('No crop locations on farm'); - } - const irrigationTasksWithExternalId = await TaskModel.getIrrigationTasksWithExternalIdByFarm( - farmId, - [MOCK_EXTERNAL_PRESCRIPTION_ID1, MOCK_EXTERNAL_PRESCRIPTION_ID2], - ); - - return [ - { - id: MOCK_EXTERNAL_PRESCRIPTION_ID1, - location_id: mockLocation.location_id, - recommended_start_datetime: afterDate - ? new Date(afterDate).toISOString() - : new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), - partner_id: PARTNER_ID, - task_id: irrigationTasksWithExternalId.find( - (task) => - task.irrigation_task.irrigation_prescription_external_id === - MOCK_EXTERNAL_PRESCRIPTION_ID1, - )?.task_id, - }, - { - id: MOCK_EXTERNAL_PRESCRIPTION_ID2, - location_id: locations.at(-1)?.location_id, - recommended_start_datetime: afterDate - ? new Date(new Date(afterDate).getTime() + ONE_DAY_IN_MS).toISOString() - : new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), - partner_id: PARTNER_ID, - task_id: irrigationTasksWithExternalId.find( - (task) => - task.irrigation_task.irrigation_prescription_external_id === - MOCK_EXTERNAL_PRESCRIPTION_ID2, - )?.task_id, - }, - ]; -}; - /** * Returns a list of mocked prescriptions based on a specific farm_id. * diff --git a/packages/api/tests/irrigation_prescription.test.ts b/packages/api/tests/irrigation_prescription.test.ts index bae8ba0d84..a51ecdd090 100644 --- a/packages/api/tests/irrigation_prescription.test.ts +++ b/packages/api/tests/irrigation_prescription.test.ts @@ -108,6 +108,7 @@ describe('Get Irrigation Prescription Tests', () => { farm_id: farm.farm_id, user_id: user.user_id, data: mocks.fakeTask({ + locations: [{ location_id: field.location_id }], task_type_id: taskTypeInDb.task_type_id, irrigation_task: mocks.fakeIrrigationTask({ location_id: field.location_id, @@ -117,12 +118,16 @@ describe('Get Irrigation Prescription Tests', () => { }); const { farmAddon } = await connectFarmToEnsemble(farm); + const irrigationPrescriptions = await fakeIrrigationPrescriptions(farm.farm_id, [ + MOCK_EXTERNAL_PRESCRIPTION_ID1, + MOCK_EXTERNAL_PRESCRIPTION_ID2, + ]); + + expect(irrigationPrescriptions.length).toBe(2); + expect(irrigationPrescriptions[0].location_id).toBe(field.location_id); + (mockedAxios as unknown as jest.Mock).mockResolvedValue({ - data: fakeIrrigationPrescriptions( - farm.farm_id, - [MOCK_EXTERNAL_PRESCRIPTION_ID1, MOCK_EXTERNAL_PRESCRIPTION_ID2], - field.location_id, - ), + data: irrigationPrescriptions, status: 200, statusText: 'OK', headers: {}, diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index 8315bada9a..6da56bf2bb 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -30,6 +30,7 @@ export const connectFarmToEnsemble = async (farm: Farm) => { /** * Returns a list of mocked prescriptions based on a specific farm_id. + * TODO: refactor once it is no longer used on beta to be tests specific * * @param farm_id - The ID of the farm to retrieve mock data for. * @returns A promise that resolves to formatted irrigation prescription data. @@ -37,7 +38,8 @@ export const connectFarmToEnsemble = async (farm: Farm) => { export const fakeIrrigationPrescriptions = async ( farmId: Farm['farm_id'], prescriptionIds: IrrigationPrescription['id'][] = [1, 2], - locationId: Location['location_id'], + locationIds?: Location['location_id'], + afterDate?: string, ): Promise => { const PARTNER_ID = 1; const ONE_HOUR_IN_MS = 1000 * 60 * 60; @@ -55,18 +57,27 @@ export const fakeIrrigationPrescriptions = async ( (task) => task.irrigation_task.irrigation_prescription_external_id === prescriptionIds[1], ); + if (!irrigationTask1 || !irrigationTask1.irrigation_task.location_id || !locationIds?.length) { + return []; + } + return [ { id: prescriptionIds[0], - location_id: irrigationTask1?.irrigation_task.location_id ?? locationId, - recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), + location_id: irrigationTask1.irrigation_task.location_id, + recommended_start_datetime: afterDate + ? new Date(afterDate).toISOString() + : new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), partner_id: PARTNER_ID, task_id: irrigationTask1?.task_id, }, { id: prescriptionIds[1], - location_id: irrigationTask2?.irrigation_task.location_id ?? locationId, - recommended_start_datetime: new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), + location_id: + irrigationTask2?.irrigation_task.location_id ?? irrigationTask1.irrigation_task.location_id, + recommended_start_datetime: afterDate + ? new Date(new Date(afterDate).getTime() + ONE_DAY_IN_MS).toISOString() + : new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), partner_id: PARTNER_ID, task_id: irrigationTask1?.task_id, }, From 867842f4f8588f54bf8bed22b3996f05ee141ee6 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 7 May 2025 12:51:00 -0400 Subject: [PATCH 30/59] LF-4765 improve/fix mock logic --- packages/api/tests/utils/ensembleUtils.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index 6da56bf2bb..eaaa376321 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -57,14 +57,18 @@ export const fakeIrrigationPrescriptions = async ( (task) => task.irrigation_task.irrigation_prescription_external_id === prescriptionIds[1], ); - if (!irrigationTask1 || !irrigationTask1.irrigation_task.location_id || !locationIds?.length) { + const locationId1 = irrigationTask1?.irrigation_task?.location_id ?? locationIds?.[0]; + const locationId2 = + irrigationTask2?.irrigation_task.location_id ?? locationIds?.[1] ?? locationId1; + + if (!locationId1 || !locationId2) { return []; } return [ { id: prescriptionIds[0], - location_id: irrigationTask1.irrigation_task.location_id, + location_id: locationId1, recommended_start_datetime: afterDate ? new Date(afterDate).toISOString() : new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), @@ -73,8 +77,7 @@ export const fakeIrrigationPrescriptions = async ( }, { id: prescriptionIds[1], - location_id: - irrigationTask2?.irrigation_task.location_id ?? irrigationTask1.irrigation_task.location_id, + location_id: locationId2, recommended_start_datetime: afterDate ? new Date(new Date(afterDate).getTime() + ONE_DAY_IN_MS).toISOString() : new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), From 4860c06383b1252d03ab345064358175aea2672f Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 7 May 2025 12:59:46 -0400 Subject: [PATCH 31/59] LF-4765 Adjust frontend query to backend specs, move mocks to backend --- packages/webapp/src/apiConfig.js | 2 +- .../useIrrigationPrescriptions.ts | 27 +++---------------- packages/webapp/src/store/api/apiSlice.ts | 9 +++++-- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/packages/webapp/src/apiConfig.js b/packages/webapp/src/apiConfig.js index e689ba1735..9be8b3f653 100644 --- a/packages/webapp/src/apiConfig.js +++ b/packages/webapp/src/apiConfig.js @@ -92,7 +92,7 @@ export const soilAmendmentPurposesUrl = `${URI}/soil_amendment_purposes`; export const soilAmendmentFertiliserTypesUrl = `${URI}/soil_amendment_fertiliser_types`; export const productUrl = `${URI}/product`; export const farmAddonUrl = `${URI}/farm_addon`; -export const irrigationPrescriptionsUrl = `${URI}/irrigation_prescriptions`; +export const irrigationPrescriptionUrl = `${URI}/irrigation_prescription`; export const url = URI; diff --git a/packages/webapp/src/containers/LocationDetails/LocationIrrigation/useIrrigationPrescriptions.ts b/packages/webapp/src/containers/LocationDetails/LocationIrrigation/useIrrigationPrescriptions.ts index c42dc978a6..2cc3a8fc51 100644 --- a/packages/webapp/src/containers/LocationDetails/LocationIrrigation/useIrrigationPrescriptions.ts +++ b/packages/webapp/src/containers/LocationDetails/LocationIrrigation/useIrrigationPrescriptions.ts @@ -24,38 +24,17 @@ interface LocationIrrigationPrescription extends IrrigationPrescription { task?: Task; } -const ONE_HOUR_IN_MS = 1000 * 60 * 60; -const getMockData = (location: Location, tasks: Task[]): IrrigationPrescription[] => [ - { - id: 1, - location_id: location.location_id, - recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toDateString(), - partner_id: 1, - task_id: tasks.length ? tasks.at(-1)?.task_id : undefined, - }, - { - id: 2, - location_id: location.location_id, - recommended_start_datetime: new Date(Date.now() - ONE_HOUR_IN_MS).toDateString(), - partner_id: 1, - task_id: undefined, - }, -]; - export default function useIrrigationPrescriptions(location?: Location) { if (!location) { return []; } - const { data } = useGetIrrigationPrescriptionsQuery(); + const { data = [] } = useGetIrrigationPrescriptionsQuery(); const tasks = useSelector(tasksSelector); - // TODO: refactor once mocked data is no longer needed - const irrigationPrescriptions = data ?? getMockData(location, tasks); - let filteredIrrigationPrescriptionsWithTask: LocationIrrigationPrescription[] = []; - if (location && location.grid_points) { - filteredIrrigationPrescriptionsWithTask = irrigationPrescriptions + if (location.grid_points) { + filteredIrrigationPrescriptionsWithTask = data .filter( // return matching plans for this location ({ location_id }) => location_id === location.location_id, diff --git a/packages/webapp/src/store/api/apiSlice.ts b/packages/webapp/src/store/api/apiSlice.ts index fe321d23c1..c79b525f29 100644 --- a/packages/webapp/src/store/api/apiSlice.ts +++ b/packages/webapp/src/store/api/apiSlice.ts @@ -36,7 +36,7 @@ import { animalMovementPurposesUrl, sensorUrl, farmAddonUrl, - irrigationPrescriptionsUrl, + irrigationPrescriptionUrl, } from '../../apiConfig'; import type { Animal, @@ -295,7 +295,12 @@ export const api = createApi({ error ? [] : ['FarmAddon', 'Sensors', 'SensorReadings'], }), getIrrigationPrescriptions: build.query({ - query: () => `${irrigationPrescriptionsUrl}`, + query: () => { + // After date is hard coded for now as the users current locale + const after_date = new Date().toISOString(); + const params = new URLSearchParams({ after_date }); + return `${irrigationPrescriptionUrl}?${params.toString()}`; + }, async onQueryStarted(_id, { dispatch, queryFulfilled }) { try { // TODO: Once tasks is migrated to rtk use invalidatesTags instead of onQueryStarted' From 406ca29de419304073f6824cf09a91c389cfbb4f Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 7 May 2025 15:04:33 -0400 Subject: [PATCH 32/59] LF-4765 Update date params for irrigation prescription endpoints --- .../controllers/irrigationPrescriptionController.ts | 11 ++++++----- packages/api/src/util/ensembleService.ts | 6 ++++-- packages/api/tests/utils/ensembleUtils.ts | 11 ++++++----- packages/webapp/src/store/api/apiSlice.ts | 10 +++++++--- packages/webapp/src/util/date-migrate-TS.ts | 12 ++++++++++++ 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index 648f8db210..3a75fde7de 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -19,7 +19,8 @@ import { getEsciPrescriptions } from '../util/ensembleService.js'; import { fakeIrrigationPrescriptions } from '../../tests/utils/ensembleUtils.js'; interface DELETEMEQueryParams { - after_date?: string; + startTime?: string; + endTime?: string; shouldSend?: string; } @@ -28,11 +29,10 @@ const irrigationPrescriptionController = { return async (req: LiteFarmRequest, res: Response) => { try { const { farm_id } = req.headers; - const { after_date, shouldSend } = req.query; - + const { startTime, endTime, shouldSend } = req.query; if (shouldSend === 'true') { // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - const prescriptions = await getEsciPrescriptions(farm_id, after_date); + const prescriptions = await getEsciPrescriptions(farm_id, startTime, endTime); return res.status(200).send(prescriptions); } else { // Return data for dev purposes + QA @@ -41,7 +41,8 @@ const irrigationPrescriptionController = { farm_id, [1, 2], undefined, - after_date, + startTime, + endTime, ); return res.status(200).send(mockData); } diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 8f01117800..4040e56a07 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -77,7 +77,8 @@ const getExternalOrganisationIds = async ( */ export const getEsciPrescriptions = async ( farmId: string, - afterDate?: string, + startTime?: string, + endTime?: string, ): Promise => { const addonPartnerId = await getAddonPartnerId(); const externalOrganizationIds = await getExternalOrganisationIds(farmId, addonPartnerId); @@ -87,7 +88,8 @@ export const getEsciPrescriptions = async ( method: 'get', url: `${ensembleAPI}/organizations/${externalOrganizationIds.org_pk}/irrigation_prescriptions`, params: { - after_date: afterDate, // ISO form or unix instead? + start_time: startTime, // ISO form + end_time: endTime, // ISO form }, }; diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index eaaa376321..113635c9ce 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -39,7 +39,8 @@ export const fakeIrrigationPrescriptions = async ( farmId: Farm['farm_id'], prescriptionIds: IrrigationPrescription['id'][] = [1, 2], locationIds?: Location['location_id'], - afterDate?: string, + startTime?: string, + endTime?: string, ): Promise => { const PARTNER_ID = 1; const ONE_HOUR_IN_MS = 1000 * 60 * 60; @@ -69,8 +70,8 @@ export const fakeIrrigationPrescriptions = async ( { id: prescriptionIds[0], location_id: locationId1, - recommended_start_datetime: afterDate - ? new Date(afterDate).toISOString() + recommended_start_datetime: startTime + ? startTime : new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), partner_id: PARTNER_ID, task_id: irrigationTask1?.task_id, @@ -78,8 +79,8 @@ export const fakeIrrigationPrescriptions = async ( { id: prescriptionIds[1], location_id: locationId2, - recommended_start_datetime: afterDate - ? new Date(new Date(afterDate).getTime() + ONE_DAY_IN_MS).toISOString() + recommended_start_datetime: endTime + ? endTime : new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), partner_id: PARTNER_ID, task_id: irrigationTask1?.task_id, diff --git a/packages/webapp/src/store/api/apiSlice.ts b/packages/webapp/src/store/api/apiSlice.ts index c79b525f29..fb6e2f5538 100644 --- a/packages/webapp/src/store/api/apiSlice.ts +++ b/packages/webapp/src/store/api/apiSlice.ts @@ -61,7 +61,8 @@ import type { SensorReadings, IrrigationPrescription, } from './types'; -import i18n from '../../locales/i18n'; +import { addDaysToDate } from '../../util/date'; +import { getEndOfDate, getStartOfDate } from '../../util/date-migrate-TS'; export const api = createApi({ baseQuery: fetchBaseQuery({ @@ -297,8 +298,11 @@ export const api = createApi({ getIrrigationPrescriptions: build.query({ query: () => { // After date is hard coded for now as the users current locale - const after_date = new Date().toISOString(); - const params = new URLSearchParams({ after_date }); + const today = new Date(); + const startTime = getStartOfDate(today).toISOString(); + const endTime = getEndOfDate(addDaysToDate(today, 1)).toISOString(); + const params = new URLSearchParams({ startTime, endTime }); + return `${irrigationPrescriptionUrl}?${params.toString()}`; }, async onQueryStarted(_id, { dispatch, queryFulfilled }) { diff --git a/packages/webapp/src/util/date-migrate-TS.ts b/packages/webapp/src/util/date-migrate-TS.ts index cf111a5ad0..50d66b4514 100644 --- a/packages/webapp/src/util/date-migrate-TS.ts +++ b/packages/webapp/src/util/date-migrate-TS.ts @@ -49,3 +49,15 @@ export function isLessThanTwelveHrsAgo(datetime: Date): boolean { export const getIntlDate = (date: Date, language: Intl.LocalesArgument = 'en') => { return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(new Date(date)); }; + +export const getStartOfDate = (date: Date): Date => { + const newDate = new Date(date); + newDate.setHours(0, 0, 0, 0); + return newDate; +}; + +export const getEndOfDate = (date: Date): Date => { + const newDate = new Date(date); + newDate.setHours(23, 59, 59, 999); + return newDate; +}; From ed4a117045545c516098da175b1db0d5a824210e Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 7 May 2025 21:51:06 -0400 Subject: [PATCH 33/59] LF-4765 Add management plan type and dependencies --- packages/api/src/models/types.ts | 200 +++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/packages/api/src/models/types.ts b/packages/api/src/models/types.ts index 53001b8dbe..ce0d70c5fa 100644 --- a/packages/api/src/models/types.ts +++ b/packages/api/src/models/types.ts @@ -382,3 +382,203 @@ export type IrrigationTaskDetails = { export interface IrrigationTask extends Task { irrigation_task: IrrigationTaskDetails; } + +enum SeedingType { + SEED = 'SEED', + SEEDLING_OR_PLANTING_STOCK = 'SEEDLING_OR_PLANTING_STOCK', +} + +enum CropLifecycleType { + ANNUAL = 'ANNUAL', + PERENNIAL = 'PERENNIAL', +} + +enum IsTreated { + YES = 'YES', + NO = 'NO', + NOT_SURE = 'NOT_SURE', +} + +enum PlantingMethod { + BROADCAST_METHOD = 'BROADCAST_METHOD', + CONTAINER_METHOD = 'CONTAINER_METHOD', + BED_METHOD = 'BED_METHOD', + ROW_METHOD = 'ROW_METHOD', +} + +type CropNutrients = { + energy: number; + ca: number; + fe: number; + mg: number; + k: number; + na: number; + zn: number; + cu: number; + fl: number; + mn: number; + vita_rae: number; + vitc: number; + thiamin: number; + riboflavin: number; + niacin: number; + vitb6: number; + folate: number; + vitb12: number; + nutrient_credits: number; +}; + +enum CropGroup { + FRUIT_AND_NUTS = 'Fruit and nuts', + OTHER_CROPS = 'Other crops', + STIMULANT_SPICE_AND_AROMATIC_CROPS = 'Stimulant, spice and aromatic crops', + VEGETABLES_AND_MELONS = 'Vegetables and melons', + CEREALS = 'Cereals', + HIGH_STARCH_ROOT_TUBER_CROPS = 'High starch root/tuber crops', + OILSEED_CROPS_AND_OLEAGINOUS_FRUITS = 'Oilseed crops and oleaginous fruits', + LEGUMINOUS_CROPS = 'Leguminous crops', + SUGAR_CROPS = 'Sugar crops', + POTATOES_AND_YAMS = 'Potatoes and yams', + BEVERAGE_AND_SPICE_CROPS = 'Beverage and spice crops', +} + +enum CropSubgroup { + BERRIES = 'Berries', + CEREALS = 'Cereals', + CITRUS_FRUITS = 'Citrus fruits', + FIBRE_CROPS = 'Fibre crops', + FLOWER_CROPS = 'Flower crops', + FRUIT_BEARING_VEGETABLES = 'Fruit-bearing vegetables', + GRAPES = 'Grapes', + GRASSES_AND_OTHER_FODDER_CROPS = 'Grasses and other fodder crops', + HIGH_STARCH_ROOT_TUBER_CROPS = 'High starch root/tuber crops', + LEAFY_OR_STEM_VEGETABLES = 'Leafy or stem vegetables', + LEGUMINOUS_CROPS = 'Leguminous crops', + LENTILS = 'Lentils', + MEDICINAL_PESTICIDAL_OR_SIMILAR_CROPS = 'Medicinal, pesticidal or similar crops', + MELONS = 'Melons', + MIXED_CEREALS = 'Mixed cereals', + MUSHROOMS_AND_TRUFFLES = 'Mushrooms and truffles', + NUTS = 'Nuts', + OILSEED_CROPS_AND_OLEAGINOUS_FRUITS = 'Oilseed crops and oleaginous fruits', + OTHER_CROPS = 'Other crops', + OTHER_FRUITS = 'Other fruits', + OTHER_ROOTS_AND_TUBERS = 'Other roots and tubers', + OTHER_TEMPORARY_OILSEED_CROPS = 'Other temporary oilseed crops', + PERMANENT_OILSEED_CROPS = 'Permanent oilseed crops', + POME_FRUITS_AND_STONE_FRUITS = 'Pome fruits and stone fruits', + ROOT_BULB_OR_TUBEROUS_VEGETABLES = 'Root, bulb or tuberous vegetables', + RUBBER = 'Rubber', + SPICE_AND_AROMATIC_CROPS = 'Spice and aromatic crops', + STIMULANT_CROPS = 'Stimulant crops', + SUGAR_CROPS = 'Sugar crops', + TOBACCO = 'Tobacco', + TROPICAL_AND_SUBTROPICAL_FRUITS = 'Tropical and subtropical fruits', +} + +type SeedingAndPlantingDetails = { + seeding_type: SeedingType; + lifecycle: CropLifecycleType; + planting_method?: PlantingMethod; + can_be_cover_crop?: boolean; + planting_depth?: number; + yield_per_area?: number; + average_seed_weight?: number; + yield_per_plant?: number; + plant_spacing?: number; + needs_transplant?: boolean; + germination_days?: number; + transplant_days?: number; + harvest_days?: number; + termination_days?: number; + seeding_rate?: number; + hs_code_id?: string | number; +}; +export interface Crop extends BaseProperties, CropNutrients, Partial { + crop_id: number; + farm_id: Farm['farm_id']; + crop_common_name: string; + crop_variety: string; + crop_genus: string; + crop_specie: string; + crop_group?: CropGroup; + crop_subgroup?: CropSubgroup; + max_rooting_depth: number; + depletion_fraction: number; + initial_kc: number; + mid_kc: number; + end_kc: number; + max_height: number; + percentrefuse: number; + protein: number; + lipid: number; + fl: number; + se: number; + vite: number; + pantothenic: number; + vitk: number; + is_avg_depth?: boolean; + is_avg_nutrient?: boolean; + is_avg_kc?: boolean; + user_added: boolean; + nutrient_notes: string; + refuse: string; + reviewed: boolean; + crop_translation_key: string; + crop_photo_url: string; +} +export interface CropVariety + extends BaseProperties, + Partial, + SeedingAndPlantingDetails { + crop_variety_id: string; + crop_id: Crop['crop_id']; + farm_id: Farm['farm_id']; + crop_variety_name?: string; + crop_varietal?: string; + crop_cultivar?: string; + supplier?: string; + compliance_file_url?: string; + organic?: boolean; + treated?: IsTreated; + genetically_engineered?: boolean; + searched?: boolean; + protein?: number; + lipid?: number; + ph?: number; + crop_variety_photo_url: string; +} + +enum Rating { + ZERO, + ONE, + TWO, + THREE, + FOUR, + FIVE, +} + +// TODO: Where is this info? +type RepetitionConfig = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; +export interface ManagementPlanGroup extends BaseProperties { + management_plan_group_id: string; + repetition_count: number; + repetition_config: RepetitionConfig; +} +export interface ManagementPlan extends BaseProperties { + management_plan_id: number; + crop_variety_id: CropVariety['crop_variety_id']; + name: string; + notes?: string; + abandon_date?: string; + start_date?: string; + complete_date?: string; + complete_notes?: string; + rating?: Rating; + abandon_reason?: string; + management_plan_group_id: ManagementPlanGroup['management_plan_group_id']; + repetition_number?: number; +} From 43899c1be33134d28427ebd14ffa5c76075c8cf8 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 7 May 2025 21:51:43 -0400 Subject: [PATCH 34/59] LF-4765 Add mamangement plan to expected return mock and type --- packages/api/src/util/ensembleService.types.ts | 3 ++- packages/api/tests/utils/ensembleUtils.ts | 7 +++++-- packages/webapp/src/store/api/types/index.ts | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/api/src/util/ensembleService.types.ts b/packages/api/src/util/ensembleService.types.ts index eaece35f80..1d04086f77 100644 --- a/packages/api/src/util/ensembleService.types.ts +++ b/packages/api/src/util/ensembleService.types.ts @@ -94,7 +94,8 @@ export type FarmAddon = { export type ExternalIrrigationPrescription = { id: number; - location_id: number | string; + location_id: string; + management_plan_id?: number; recommended_start_datetime: string; }; diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index 113635c9ce..66ecb574c3 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -17,7 +17,7 @@ import mocks from '../mock.factories.js'; import { ENSEMBLE_BRAND } from '../../src/util/ensemble.js'; import { IrrigationPrescription } from '../../src/util/ensembleService.types.js'; import TaskModel from '../../src/models/taskModel.js'; -import { Farm, Location } from '../../src/models/types.js'; +import { Farm, Location, ManagementPlan } from '../../src/models/types.js'; export const connectFarmToEnsemble = async (farm: Farm) => { const [farmAddon] = await mocks.farm_addonFactory({ @@ -38,7 +38,8 @@ export const connectFarmToEnsemble = async (farm: Farm) => { export const fakeIrrigationPrescriptions = async ( farmId: Farm['farm_id'], prescriptionIds: IrrigationPrescription['id'][] = [1, 2], - locationIds?: Location['location_id'], + locationIds?: Location['location_id'][], + managementPlanIds?: ManagementPlan['management_plan_id'][], startTime?: string, endTime?: string, ): Promise => { @@ -70,6 +71,7 @@ export const fakeIrrigationPrescriptions = async ( { id: prescriptionIds[0], location_id: locationId1, + management_plan_id: managementPlanIds?.[0] || undefined, recommended_start_datetime: startTime ? startTime : new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), @@ -79,6 +81,7 @@ export const fakeIrrigationPrescriptions = async ( { id: prescriptionIds[1], location_id: locationId2, + management_plan_id: managementPlanIds?.[1] || undefined, recommended_start_datetime: endTime ? endTime : new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), diff --git a/packages/webapp/src/store/api/types/index.ts b/packages/webapp/src/store/api/types/index.ts index 795ef48653..a91a1798b5 100644 --- a/packages/webapp/src/store/api/types/index.ts +++ b/packages/webapp/src/store/api/types/index.ts @@ -320,6 +320,7 @@ export interface SensorReadings { export interface IrrigationPrescription { id: number; location_id: string; + management_plan_id?: number | string; recommended_start_datetime: string; partner_id: number; task_id?: number | string; From f72a318a34697c000e45791744d8bb59d37b4e96 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 7 May 2025 22:01:05 -0400 Subject: [PATCH 35/59] LF-4765 rename what was a temporary variable to a permanent one --- .../api/src/controllers/irrigationPrescriptionController.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index 3a75fde7de..e27e1cd361 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -18,7 +18,7 @@ import { LiteFarmRequest, HttpError } from '../types.js'; import { getEsciPrescriptions } from '../util/ensembleService.js'; import { fakeIrrigationPrescriptions } from '../../tests/utils/ensembleUtils.js'; -interface DELETEMEQueryParams { +interface IrrigationPrescriptionQueryParams { startTime?: string; endTime?: string; shouldSend?: string; @@ -26,7 +26,7 @@ interface DELETEMEQueryParams { const irrigationPrescriptionController = { getPrescriptions() { - return async (req: LiteFarmRequest, res: Response) => { + return async (req: LiteFarmRequest, res: Response) => { try { const { farm_id } = req.headers; const { startTime, endTime, shouldSend } = req.query; From 8e8c1f31a88b51874d65a18190234de1457272fb Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 7 May 2025 22:17:30 -0400 Subject: [PATCH 36/59] LF-4765 Update types file with farm addon and point --- packages/api/src/models/types.ts | 36 ++++++++++++------- packages/api/src/util/ensembleService.ts | 3 +- .../api/src/util/ensembleService.types.ts | 13 +------ 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/api/src/models/types.ts b/packages/api/src/models/types.ts index ce0d70c5fa..dc38b46922 100644 --- a/packages/api/src/models/types.ts +++ b/packages/api/src/models/types.ts @@ -13,12 +13,11 @@ * GNU General Public License for more details, see . */ -import { Point } from '../util/ensembleService.types.js'; - /** * This file should create and hold types that are identical to the model types. + * Use Utility types or other means if not suiting purpose * - * TODO: RelationMappings -- not optional, a discriminted type (eg IrrigationTask) + * TODO: RelationMappings -- not optional properties but a discriminated type (see IrrigationTask) * * How to use: * - Keep identical to model @@ -76,15 +75,6 @@ interface BaseProperties extends UserTimeStamps { deleted: boolean; } -export type AddonPartner = { - id: number; - name: string; - access_token: string; - refresh_token: string; - root_url: string; - deactivated: boolean; -}; - enum FarmUnitSystem { IMPERIAL = 'imperial', METRIC = 'metric', @@ -282,6 +272,11 @@ export type Country = { unit: string; }; +export type Point = { + lat: number; + lng: number; +}; + export interface Farm extends BaseProperties { farm_id: string; farm_name: string; @@ -582,3 +577,20 @@ export interface ManagementPlan extends BaseProperties { management_plan_group_id: ManagementPlanGroup['management_plan_group_id']; repetition_number?: number; } + +export type AddonPartner = { + id: number; + name: string; + access_token: string; + refresh_token: string; + root_url: string; + deactivated: boolean; +}; + +export interface FarmAddon extends BaseProperties { + id: number; + farm_id: Farm['farm_id']; + addon_partner_id: AddonPartner['id']; + org_uuid: string; + org_pk: number; +} diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 4040e56a07..3aa73fbf60 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -28,13 +28,12 @@ import { type LocationAndCropGraph, type EnsembleLocationAndCropData, type ManagementPlan, - type FarmAddon, type IrrigationPrescription, ExternalIrrigationPrescription, isExternalIrrigationPrescriptionArray, } from './ensembleService.types.js'; import TaskModel from '../models/taskModel.js'; -import { AddonPartner, Farm } from '../models/types.js'; +import { AddonPartner, Farm, FarmAddon } from '../models/types.js'; /** * Retrieves the external organisation IDs for a specific farm and partner. diff --git a/packages/api/src/util/ensembleService.types.ts b/packages/api/src/util/ensembleService.types.ts index 1d04086f77..846c25f88f 100644 --- a/packages/api/src/util/ensembleService.types.ts +++ b/packages/api/src/util/ensembleService.types.ts @@ -13,10 +13,7 @@ * GNU General Public License for more details, see . */ -export interface Point { - lat: number; - lng: number; -} +import { Point } from '../models/types.js'; enum PlantingMethod { BED_METHOD = 'bed_method', @@ -84,14 +81,6 @@ export interface OrganisationFarmData { [org_uuid: string]: EnsembleLocationAndCropData[]; } -export type FarmAddon = { - id: number; - farm_id: string; - addon_partner_id: number; - org_uuid: string; - org_pk: number; -}; - export type ExternalIrrigationPrescription = { id: number; location_id: string; From 3940875caea81d6d787b68526094362a51e06a1d Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 8 May 2025 15:16:32 -0400 Subject: [PATCH 37/59] LF-4765 Invalidate irrigation prescription query between logouts --- packages/webapp/src/containers/saga.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/webapp/src/containers/saga.js b/packages/webapp/src/containers/saga.js index 51050f57f7..efc8ca8467 100644 --- a/packages/webapp/src/containers/saga.js +++ b/packages/webapp/src/containers/saga.js @@ -631,6 +631,7 @@ export function* clearOldFarmStateSaga() { 'CustomAnimalTypes', 'DefaultAnimalTypes', // needs to be cleared for KPI count 'FarmAddon', + 'IrrigationPrescriptions', ]), ); From 6aa5d17a093efc5e4739544bb4d655fcd7bdbb99 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 8 May 2025 15:19:38 -0400 Subject: [PATCH 38/59] LF-4765 Send empty array if no farm addons that provide irrigation prescriptions --- .../irrigationPrescriptionController.ts | 47 +++++++++++++++++-- packages/api/src/models/farmAddonModel.js | 13 +++++ packages/api/src/util/ensembleService.ts | 7 ++- .../api/src/util/ensembleService.types.ts | 8 ++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index e27e1cd361..ff367c9592 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -15,8 +15,10 @@ import { Response } from 'express'; import { LiteFarmRequest, HttpError } from '../types.js'; -import { getEsciPrescriptions } from '../util/ensembleService.js'; +import ESciAddon from '../util/ensembleService.js'; import { fakeIrrigationPrescriptions } from '../../tests/utils/ensembleUtils.js'; +import FarmAddonModel from '../models/farmAddonModel.js'; +import { AddonFunctions, IrrigationPrescription } from '../util/ensembleService.types.js'; interface IrrigationPrescriptionQueryParams { startTime?: string; @@ -24,16 +26,55 @@ interface IrrigationPrescriptionQueryParams { shouldSend?: string; } +// TODO: LF-4710 - Delete partner_id = 0, remove Partial +const PARTNER_ID_MAP: Record> = { + '0': {}, + '1': ESciAddon, +}; + const irrigationPrescriptionController = { getPrescriptions() { return async (req: LiteFarmRequest, res: Response) => { try { const { farm_id } = req.headers; const { startTime, endTime, shouldSend } = req.query; + const irrigationPrescriptions: IrrigationPrescription[] = []; + const partnerErrors: unknown[] = []; + if (shouldSend === 'true') { + // Check for registered farm addons (only esci for now) // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - const prescriptions = await getEsciPrescriptions(farm_id, startTime, endTime); - return res.status(200).send(prescriptions); + const farmAddonPartnerIds = await FarmAddonModel.getDistinctFarmAddonPartnerIds(farm_id); + + // Return empty array if no addons + if (!farmAddonPartnerIds.length) { + return res.status(200).send(irrigationPrescriptions); + } + + // Loop through addon partners + for (const farmAddonPartnerId of farmAddonPartnerIds) { + try { + const addonPartner = PARTNER_ID_MAP[farmAddonPartnerId.addon_partner_id.toString()]; + // TODO: LF-4710 - Skip deprecated partner_id = 0 situation + // Type guard for undefined functions + if (!addonPartner || typeof addonPartner.getIrrigationPrescriptions !== 'function') { + continue; + } + + irrigationPrescriptions.push( + // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument + ...(await addonPartner.getIrrigationPrescriptions(farm_id, startTime, endTime)), + ); + } catch (error) { + partnerErrors.push(error); + } + } + + // Return an error if there are no prescriptions + if (!irrigationPrescriptions.length && partnerErrors.length) { + throw partnerErrors.shift(); + } + return res.status(200).send(irrigationPrescriptions); } else { // Return data for dev purposes + QA const mockData = await fakeIrrigationPrescriptions( diff --git a/packages/api/src/models/farmAddonModel.js b/packages/api/src/models/farmAddonModel.js index 1a6de7bac2..21aa24b8c9 100644 --- a/packages/api/src/models/farmAddonModel.js +++ b/packages/api/src/models/farmAddonModel.js @@ -121,6 +121,19 @@ class FarmAddon extends baseModel { .where('addon_partner_id', addonPartnerId) .whereNotDeleted(); } + + /** + * Retrieves all organization identifiers (uuid, pk) for a given addon partner. + * + * @param {string} farmId - The ID of the farm. + * @returns {Promise>} The organization identifiers and the farm they are associated with + */ + static async getDistinctFarmAddonPartnerIds(farmId) { + return FarmAddon.query() + .distinct('addon_partner_id') + .where('farm_id', farmId) + .whereNotDeleted(); + } } export default FarmAddon; diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 3aa73fbf60..e160d74141 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -74,7 +74,7 @@ const getExternalOrganisationIds = async ( * @param farm_id - The ID of the farm to retrieve mock data for. * @returns A promise that resolves to formatted irrigation prescription data. */ -export const getEsciPrescriptions = async ( +export const getIrrigationPrescriptions = async ( farmId: string, startTime?: string, endTime?: string, @@ -244,3 +244,8 @@ function selectCropData(managementPlans: ManagementPlan[]) { }; }); } + +const ESciAddon = { + getIrrigationPrescriptions, +}; +export default ESciAddon; diff --git a/packages/api/src/util/ensembleService.types.ts b/packages/api/src/util/ensembleService.types.ts index 846c25f88f..3d05061293 100644 --- a/packages/api/src/util/ensembleService.types.ts +++ b/packages/api/src/util/ensembleService.types.ts @@ -113,3 +113,11 @@ export function isExternalIrrigationPrescriptionArray( }) ); } + +export interface AddonFunctions { + getIrrigationPrescriptions: ( + farmId: string, + startTime?: string, + endTime?: string, + ) => Promise; +} From 6edc866b88367adfb847fbf8bbe7f8787214a788 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 9 May 2025 13:49:18 -0400 Subject: [PATCH 39/59] LF-4765 Add frontend date utils to backend tests utils --- packages/api/tests/utils/date.ts | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/api/tests/utils/date.ts diff --git a/packages/api/tests/utils/date.ts b/packages/api/tests/utils/date.ts new file mode 100644 index 0000000000..db063e9aa1 --- /dev/null +++ b/packages/api/tests/utils/date.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +/** + * Adds a specified number of days to a given date. + * + * @param date - The initial date to which days should be added. ex. new Date(2023, 10, 1) + * @param days - The number of days to add to the initial date. (Could be a negative number) + * @returns - A new Date object representing the result of adding the specified days to the initial date. + */ +export function addDaysToDate(date: Date, days: number): Date { + const newDate = new Date(date.getTime()); + return new Date(newDate.setDate(newDate.getDate() + days)); +} + +/** + * Sets a specified date to midnight or 00:00.000. + * + * @param date - The initial date to which the hours will be set + * @returns - A new Date object representing the result of setting the date to midnight. + */ +export function getStartOfDate(date: Date): Date { + const newDate = new Date(date); + newDate.setHours(0, 0, 0, 0); + return newDate; +} + +/** + * Sets a specified date to 1 millisecond before midnight. + * + * @param date - The initial date to which the hours will be set + * @returns - A new Date object representing the result of setting the date to 1 millisecond before midnight. + */ +export function getEndOfDate(date: Date): Date { + const newDate = new Date(date); + newDate.setHours(23, 59, 59, 999); + return newDate; +} From cda8040043433a106a9eeb855852db791ac72cdb Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 9 May 2025 14:01:07 -0400 Subject: [PATCH 40/59] LF-4765 Fix tests to accomodate new values --- .../irrigationPrescriptionController.ts | 8 ++--- .../api/tests/irrigation_prescription.test.ts | 24 +++++++++----- packages/api/tests/mock.factories.js | 4 +-- packages/api/tests/utils/ensembleUtils.ts | 33 ++++++++++++------- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index ff367c9592..07a17994ab 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -77,14 +77,12 @@ const irrigationPrescriptionController = { return res.status(200).send(irrigationPrescriptions); } else { // Return data for dev purposes + QA - const mockData = await fakeIrrigationPrescriptions( + const mockData = await fakeIrrigationPrescriptions({ // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - farm_id, - [1, 2], - undefined, + farmId: farm_id, startTime, endTime, - ); + }); return res.status(200).send(mockData); } } catch (error: unknown) { diff --git a/packages/api/tests/irrigation_prescription.test.ts b/packages/api/tests/irrigation_prescription.test.ts index a51ecdd090..ace9878f5c 100644 --- a/packages/api/tests/irrigation_prescription.test.ts +++ b/packages/api/tests/irrigation_prescription.test.ts @@ -38,10 +38,13 @@ const mockedAxios = axios as jest.Mocked; import { setupFarmEnvironment } from './utils/testDataSetup.js'; import { connectFarmToEnsemble, fakeIrrigationPrescriptions } from './utils/ensembleUtils.js'; -import type { Farm, IrrigationTask, User } from '../src/models/types.js'; +import type { AddonPartner, Farm, IrrigationTask, User } from '../src/models/types.js'; import mocks from './mock.factories.js'; +import { addDaysToDate, getEndOfDate, getStartOfDate } from './utils/date.js'; +import { ENSEMBLE_BRAND } from '../src/util/ensemble.js'; describe('Get Irrigation Prescription Tests', () => { + let ESciAddonPartner: AddonPartner; async function postIrrigationTask({ farm_id, user_id, @@ -62,12 +65,14 @@ describe('Get Irrigation Prescription Tests', () => { async function getIrrigationPrescription({ farm_id, user_id, - afterDate = new Date().toISOString(), + startTime = getStartOfDate(new Date()).toISOString(), + endTime = getEndOfDate(addDaysToDate(new Date(), 1)).toISOString(), shouldSend = 'true', }: { farm_id: Farm['farm_id']; user_id: User['user_id']; - afterDate?: string; + startTime?: string; + endTime?: string; shouldSend?: string; }): Promise { return chai @@ -76,12 +81,13 @@ describe('Get Irrigation Prescription Tests', () => { .set('content-type', 'application/json') .set('farm_id', farm_id) .set('user_id', user_id) - .query({ afterDate, shouldSend }); + .query({ startTime, endTime, shouldSend }); } beforeEach(async () => { (mockedAxios as unknown as jest.Mock).mockClear(); await mocks.populateTaskTypes(); + [ESciAddonPartner] = await mocks.addon_partnerFactory({ name: ENSEMBLE_BRAND, id: 1 }); }); afterEach(async () => { @@ -102,6 +108,7 @@ describe('Get Irrigation Prescription Tests', () => { task_translation_key: 'IRRIGATION_TASK', }); const { farm, field, user } = await setupFarmEnvironment(role); + const { farmAddon } = await connectFarmToEnsemble(farm, ESciAddonPartner); // Make one task for prescription 1 await postIrrigationTask({ @@ -117,11 +124,10 @@ describe('Get Irrigation Prescription Tests', () => { }), }); - const { farmAddon } = await connectFarmToEnsemble(farm); - const irrigationPrescriptions = await fakeIrrigationPrescriptions(farm.farm_id, [ - MOCK_EXTERNAL_PRESCRIPTION_ID1, - MOCK_EXTERNAL_PRESCRIPTION_ID2, - ]); + const irrigationPrescriptions = await fakeIrrigationPrescriptions({ + farmId: farm.farm_id, + prescriptionIds: [MOCK_EXTERNAL_PRESCRIPTION_ID1, MOCK_EXTERNAL_PRESCRIPTION_ID2], + }); expect(irrigationPrescriptions.length).toBe(2); expect(irrigationPrescriptions[0].location_id).toBe(field.location_id); diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 7fe0480466..5bcc2c272c 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -2548,10 +2548,10 @@ async function animal_type_use_relationshipFactory({ .returning('*'); } -async function addon_partnerFactory(partner = { name: faker.company.companyName() }) { +async function addon_partnerFactory(partner) { return knex('addon_partner') .insert({ - ...partner, + ...(partner ? partner : { name: faker.company.companyName() }), access_token: faker.datatype.access_token, refresh_token: faker.datatype.refresh_token, }) diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index 66ecb574c3..b09f28d700 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -17,17 +17,28 @@ import mocks from '../mock.factories.js'; import { ENSEMBLE_BRAND } from '../../src/util/ensemble.js'; import { IrrigationPrescription } from '../../src/util/ensembleService.types.js'; import TaskModel from '../../src/models/taskModel.js'; -import { Farm, Location, ManagementPlan } from '../../src/models/types.js'; +import { AddonPartner, Farm, Location, ManagementPlan } from '../../src/models/types.js'; -export const connectFarmToEnsemble = async (farm: Farm) => { +export const connectFarmToEnsemble = async (farm: Farm, partner?: AddonPartner) => { const [farmAddon] = await mocks.farm_addonFactory({ promisedFarm: Promise.resolve([farm]), - promisedPartner: mocks.addon_partnerFactory({ name: ENSEMBLE_BRAND }), + promisedPartner: partner + ? Promise.resolve([partner]) + : mocks.addon_partnerFactory({ name: ENSEMBLE_BRAND, id: undefined }), }); return { farmAddon }; }; +type fakeIrrigationPrescriptionsProps = { + farmId: Farm['farm_id']; + prescriptionIds?: IrrigationPrescription['id'][]; + locationIds?: Location['location_id'][]; + managementPlanIds?: ManagementPlan['management_plan_id'][]; + startTime?: string; + endTime?: string; +}; + /** * Returns a list of mocked prescriptions based on a specific farm_id. * TODO: refactor once it is no longer used on beta to be tests specific @@ -35,14 +46,14 @@ export const connectFarmToEnsemble = async (farm: Farm) => { * @param farm_id - The ID of the farm to retrieve mock data for. * @returns A promise that resolves to formatted irrigation prescription data. */ -export const fakeIrrigationPrescriptions = async ( - farmId: Farm['farm_id'], - prescriptionIds: IrrigationPrescription['id'][] = [1, 2], - locationIds?: Location['location_id'][], - managementPlanIds?: ManagementPlan['management_plan_id'][], - startTime?: string, - endTime?: string, -): Promise => { +export const fakeIrrigationPrescriptions = async ({ + farmId, + prescriptionIds = [1, 2], + locationIds, + managementPlanIds, + startTime, + endTime, +}: fakeIrrigationPrescriptionsProps): Promise => { const PARTNER_ID = 1; const ONE_HOUR_IN_MS = 1000 * 60 * 60; const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; From ff5aedf1b239ed4542c7367abb8acb0ac5502043 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 9 May 2025 14:16:35 -0400 Subject: [PATCH 41/59] LF-4765 Fix package lock --- packages/api/package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/api/package-lock.json b/packages/api/package-lock.json index e4a2f935af..40f6552c10 100644 --- a/packages/api/package-lock.json +++ b/packages/api/package-lock.json @@ -8860,10 +8860,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT" + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, "node_modules/db-errors": { "version": "0.2.3", From 7008086650f7fed7205395f3532a47dde096249d Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 9 May 2025 15:00:57 -0400 Subject: [PATCH 42/59] LF-4765 Touch up comments and types --- packages/api/src/models/farmAddonModel.js | 4 ++-- packages/api/src/models/taskModel.js | 8 ++++---- packages/api/src/types.ts | 15 +++++++++++++++ packages/api/src/util/ensemble.js | 2 +- packages/api/src/util/ensembleService.ts | 13 +++++++------ packages/api/tests/utils/ensembleUtils.ts | 2 +- 6 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/api/src/models/farmAddonModel.js b/packages/api/src/models/farmAddonModel.js index 21aa24b8c9..5251bf0a7b 100644 --- a/packages/api/src/models/farmAddonModel.js +++ b/packages/api/src/models/farmAddonModel.js @@ -123,10 +123,10 @@ class FarmAddon extends baseModel { } /** - * Retrieves all organization identifiers (uuid, pk) for a given addon partner. + * Retrieves unique addon partner ids from farm addons. * * @param {string} farmId - The ID of the farm. - * @returns {Promise>} The organization identifiers and the farm they are associated with + * @returns {Promise>} The partner identifiers for the farm queried. */ static async getDistinctFarmAddonPartnerIds(farmId) { return FarmAddon.query() diff --git a/packages/api/src/models/taskModel.js b/packages/api/src/models/taskModel.js index 99f4827d19..5ba2f1c159 100644 --- a/packages/api/src/models/taskModel.js +++ b/packages/api/src/models/taskModel.js @@ -515,13 +515,13 @@ class TaskModel extends BaseModel { } /** - * Returns farm tasks where not deleted that has an external id + * Returns farm tasks for an array of external ids * - * @param {string} farmId - the farm requesting irrigation tasks - * @param {number[]} externalIds - the farm requesting irrigation tasks + * @param {string} farmId - The farm requesting irrigation tasks. + * @param {number[]} externalIds - Array of external irrigation prescription ids of interest. * @static * @async - * @returns {import('./types.js').IrrigationTask[]} - Object array with task id property only. + * @returns {import('./types.js').IrrigationTask[]} - Returns found irrigation tasks. */ static async getIrrigationTasksWithExternalIdByFarm(farmId, externalIds) { return await TaskModel.query() diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index a1db8e1090..8e1baa586a 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -1,3 +1,18 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + import { NextFunction, Request, Response } from 'express'; export interface HttpError extends Error { diff --git a/packages/api/src/util/ensemble.js b/packages/api/src/util/ensemble.js index dbda2679b9..a237e586c8 100644 --- a/packages/api/src/util/ensemble.js +++ b/packages/api/src/util/ensemble.js @@ -592,7 +592,7 @@ async function createOrganisation(farmId) { * @param {Function} onError - a function for handling errors with the api call * @param {Function} onResponse - a function to determine how to handle the response of the api call * @param {number} retries - number of times the api call can be retried - * @returns {Object} - the response from the Ensemble API + * @returns {import('axios').AxiosResponse} - the response from the Ensemble API * @async */ async function ensembleAPICall(axiosObject, onError, onResponse = (r) => r, retries = 1) { diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 58255f2b5b..554055b2ec 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -29,7 +29,6 @@ import { type EnsembleLocationAndCropData, type ManagementPlan, type IrrigationPrescription, - ExternalIrrigationPrescription, isExternalIrrigationPrescriptionArray, } from './ensembleService.types.js'; import TaskModel from '../models/taskModel.js'; @@ -79,6 +78,7 @@ export const getIrrigationPrescriptions = async ( startTime?: string, endTime?: string, ): Promise => { + // Get external organisation ids const addonPartnerId = await getAddonPartnerId(); const externalOrganizationIds = await getExternalOrganisationIds(farmId, addonPartnerId); @@ -87,8 +87,8 @@ export const getIrrigationPrescriptions = async ( method: 'get', url: `${ensembleAPI}/organizations/${externalOrganizationIds.org_pk}/irrigation_prescriptions`, params: { - start_time: startTime, // ISO form - end_time: endTime, // ISO form + start_time: startTime, // ISO format + end_time: endTime, // ISO format }, }; @@ -99,9 +99,8 @@ export const getIrrigationPrescriptions = async ( throw customError(message, status); }; - const { data } = (await ensembleAPICall(axiosObject, onError)) as { - data: ExternalIrrigationPrescription[]; - }; + // Get and check data + const { data } = await ensembleAPICall(axiosObject, onError); if (!data?.length) { return []; @@ -111,11 +110,13 @@ export const getIrrigationPrescriptions = async ( throw customError(`${ENSEMBLE_BRAND} irrigation prescription data not in expected format`); } + // Get irrigation tasks const irrigationTasksWithExternalId = await TaskModel.getIrrigationTasksWithExternalIdByFarm( farmId, data.map(({ id }) => id), ); + // Format response const irrigationPrescriptions: IrrigationPrescription[] = data.map((irrigationPrescription) => { const foundTask = irrigationTasksWithExternalId.find( (task) => diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index b09f28d700..a1848125c7 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -24,7 +24,7 @@ export const connectFarmToEnsemble = async (farm: Farm, partner?: AddonPartner) promisedFarm: Promise.resolve([farm]), promisedPartner: partner ? Promise.resolve([partner]) - : mocks.addon_partnerFactory({ name: ENSEMBLE_BRAND, id: undefined }), + : mocks.addon_partnerFactory({ name: ENSEMBLE_BRAND }), }); return { farmAddon }; From e00783b7238fdae00d8e787d267bca6ea0bb08f5 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 15 May 2025 13:21:25 -0400 Subject: [PATCH 43/59] LF-4765 Add query check middleware --- .../validation/checkIrrigationPrescription.ts | 42 +++++++++++++++++++ .../src/routes/irrigationPrescriptionRoute.ts | 2 + 2 files changed, 44 insertions(+) create mode 100644 packages/api/src/middleware/validation/checkIrrigationPrescription.ts diff --git a/packages/api/src/middleware/validation/checkIrrigationPrescription.ts b/packages/api/src/middleware/validation/checkIrrigationPrescription.ts new file mode 100644 index 0000000000..161d60b6d1 --- /dev/null +++ b/packages/api/src/middleware/validation/checkIrrigationPrescription.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Request, Response, NextFunction } from 'express'; +import { isISO8601Format } from '../../util/validation.js'; +import { IrrigationPrescriptionQueryParams } from '../../controllers/irrigationPrescriptionController.js'; + +export function checkGetIrrigationPrescription() { + return async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + const { startTime, endTime, shouldSend } = req.query; + + if (shouldSend != 'true' && shouldSend != 'false') { + return res.status(400).send('Please provide shouldSend as true or false'); + } + + if (!startTime || !isISO8601Format(startTime)) { + return res.status(400).send('Please provide startTime in ISO 8601 format'); + } + + if (!endTime || !isISO8601Format(endTime)) { + return res.status(400).send('Please provide endTime in ISO 8601 format'); + } + + next(); + }; +} diff --git a/packages/api/src/routes/irrigationPrescriptionRoute.ts b/packages/api/src/routes/irrigationPrescriptionRoute.ts index 3106c3431c..e895a0016c 100644 --- a/packages/api/src/routes/irrigationPrescriptionRoute.ts +++ b/packages/api/src/routes/irrigationPrescriptionRoute.ts @@ -16,12 +16,14 @@ import express from 'express'; import checkScope from '../middleware/acl/checkScope.js'; import IrrigationPrescriptionController from '../controllers/irrigationPrescriptionController.js'; +import { checkGetIrrigationPrescription } from '../middleware/validation/checkIrrigationPrescription.js'; const router = express.Router(); router.get( '/', checkScope(['get:smart_irrigation']), + checkGetIrrigationPrescription(), IrrigationPrescriptionController.getPrescriptions(), ); From 199247ec94d7a339ed13a75c887fcba6380a8583 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 15 May 2025 13:28:27 -0400 Subject: [PATCH 44/59] LF-4765 Update irrigation prescription types and type guard to use new typings --- .../api/src/util/ensembleService.types.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/api/src/util/ensembleService.types.ts b/packages/api/src/util/ensembleService.types.ts index 3d05061293..270cc63e53 100644 --- a/packages/api/src/util/ensembleService.types.ts +++ b/packages/api/src/util/ensembleService.types.ts @@ -13,7 +13,13 @@ * GNU General Public License for more details, see . */ -import { Point } from '../models/types.js'; +import { + AddonPartner, + Location, + Point, + Task, + ManagementPlan as ModelManagementPlan, +} from '../models/types.js'; enum PlantingMethod { BED_METHOD = 'bed_method', @@ -83,14 +89,14 @@ export interface OrganisationFarmData { export type ExternalIrrigationPrescription = { id: number; - location_id: string; - management_plan_id?: number; + location_id: Location['location_id']; + management_plan_id?: ModelManagementPlan['management_plan_id']; recommended_start_datetime: string; }; export interface IrrigationPrescription extends ExternalIrrigationPrescription { - partner_id: number; - task_id?: number | null; + partner_id: AddonPartner['id']; + task_id?: Task['task_id']; } // Type guard for external endpoint @@ -100,14 +106,15 @@ export function isExternalIrrigationPrescriptionArray( ): data is ExternalIrrigationPrescription[] { return ( Array.isArray(data) && - data.every((item): item is IrrigationPrescription => { + data.every((item): item is ExternalIrrigationPrescription => { if (typeof item !== 'object' || item === null) return false; const obj = item as Record; return ( - (typeof obj.id === 'string' || typeof obj.id === 'number') && - (typeof obj.location_id === 'string' || typeof obj.location_id === 'number') && + typeof obj.id === 'number' && + typeof obj.location_id === 'string' && + (obj.management_plan_id === undefined || typeof obj.management_plan_id === 'number') && typeof obj.recommended_start_datetime === 'string' ); }) From 7927a3a1799dad1435ad541efc75b75c59bed501 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 15 May 2025 13:30:26 -0400 Subject: [PATCH 45/59] LF-4765 Small fixes to update comments, error array and accessing record type --- .../irrigationPrescriptionController.ts | 12 ++++++------ packages/api/src/util/ensembleService.ts | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index 07a17994ab..2923660006 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -20,16 +20,16 @@ import { fakeIrrigationPrescriptions } from '../../tests/utils/ensembleUtils.js' import FarmAddonModel from '../models/farmAddonModel.js'; import { AddonFunctions, IrrigationPrescription } from '../util/ensembleService.types.js'; -interface IrrigationPrescriptionQueryParams { +export interface IrrigationPrescriptionQueryParams { startTime?: string; endTime?: string; shouldSend?: string; } // TODO: LF-4710 - Delete partner_id = 0, remove Partial -const PARTNER_ID_MAP: Record> = { - '0': {}, - '1': ESciAddon, +const PARTNER_ID_MAP: Record> = { + 0: {}, + 1: ESciAddon, }; const irrigationPrescriptionController = { @@ -54,7 +54,7 @@ const irrigationPrescriptionController = { // Loop through addon partners for (const farmAddonPartnerId of farmAddonPartnerIds) { try { - const addonPartner = PARTNER_ID_MAP[farmAddonPartnerId.addon_partner_id.toString()]; + const addonPartner = PARTNER_ID_MAP[farmAddonPartnerId.addon_partner_id]; // TODO: LF-4710 - Skip deprecated partner_id = 0 situation // Type guard for undefined functions if (!addonPartner || typeof addonPartner.getIrrigationPrescriptions !== 'function') { @@ -72,7 +72,7 @@ const irrigationPrescriptionController = { // Return an error if there are no prescriptions if (!irrigationPrescriptions.length && partnerErrors.length) { - throw partnerErrors.shift(); + throw partnerErrors[0]; } return res.status(200).send(irrigationPrescriptions); } else { diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 554055b2ec..866eda518c 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -35,11 +35,10 @@ import TaskModel from '../models/taskModel.js'; import { AddonPartner, Farm, FarmAddon } from '../models/types.js'; /** - * Retrieves the external organisation IDs for a specific farm and partner. + * Retrieves the addon partner ID using a partners brand name. * - * @param farm_id - The ID of the farm to retrieve external organisation IDs for. - * @returns A promise that resolves to the organisation IDs for the given farm and partner. - * @throws Will throw an error if the addon partner or the farm addon is not found. + * @returns A promise that resolves to the addon partner id. + * @throws Not found error as we expect that the addon partner is found. */ const getAddonPartnerId = async (): Promise => { const partner = await AddonPartnerModel.getPartnerId(ENSEMBLE_BRAND); @@ -52,9 +51,10 @@ const getAddonPartnerId = async (): Promise => { /** * Retrieves the external organisation IDs for a specific farm and partner. * - * @param farm_id - The ID of the farm to retrieve external organisation IDs for. + * @param farmId - The ID of the farm to retrieve external organisation IDs for. + * @param addonPartnerId - The ID of addOnPartner for whose endpoint the ids are compatible with. * @returns A promise that resolves to the organisation IDs for the given farm and partner. - * @throws Will throw an error if the addon partner or the farm addon is not found. + * @throws Not found error as we expect that the farms addon partner ids exist. */ const getExternalOrganisationIds = async ( farmId: Farm['farm_id'], @@ -68,9 +68,9 @@ const getExternalOrganisationIds = async ( }; /** - * Returns a list of mocked prescriptions based on a specific farm_id. + * Returns a list of irrigation prescriptions based for a specific farm. * - * @param farm_id - The ID of the farm to retrieve mock data for. + * @param farmId - The ID of the farm to retrieve external irrigation prescriptions for. * @returns A promise that resolves to formatted irrigation prescription data. */ export const getIrrigationPrescriptions = async ( From e7a9115af72484a2e0cadf87488ecf65f4ce8c9f Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 15 May 2025 13:31:15 -0400 Subject: [PATCH 46/59] LF-4765 Add comments to regex check for use when testing with postman --- packages/api/src/util/validation.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/api/src/util/validation.ts b/packages/api/src/util/validation.ts index a685794c64..ac544ad45e 100644 --- a/packages/api/src/util/validation.ts +++ b/packages/api/src/util/validation.ts @@ -13,6 +13,31 @@ * GNU General Public License for more details, see . */ +/** + * AI-assisted documentation + * + * Matches: + * - 2025-05-15T14:30:59Z + * - 2025-05-15T14:30:59.123Z + * - 2025-05-15T14:30:59+02:00 + * - 2025-05-15T14:30:59.123-05:00 + * + * Does NOT match: + * - 2025-05-15 (date only) + * - 2025-05-15T14:30 (missing seconds) + * - Missing timezone (e.g., 2025-05-15T14:30:59) + * + * Not susceptible to RE-dos: + * - No nested quantifiers + * - All quantifiers (\d{n} and \d+) are bounded or isolated + * - The regex is anchored at the start and end (^ and $) + * + * Potential Issues: + * - 2025-99-99T99:99:99Z would match, even though it's invalid. + * - Just matching Z or +00:00 doesn’t ensure your app interprets the timezone correctly unless you parse and normalize it. + * - Millisecond flooding (extremely rare): The \d+ for milliseconds could theoretically match a very long string like .12345678901234567890... (most regex engines will cut off long matches anyway). + * + */ export function isISO8601Format(value: unknown): boolean { // https://stackoverflow.com/a/8270148/15876096 const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; From bf0310c80a9143c1e197f251913390f250323536 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 15 May 2025 13:40:03 -0400 Subject: [PATCH 47/59] LF-4765 Update jsdoc --- packages/api/src/util/ensembleService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 866eda518c..27f3322cb0 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -71,6 +71,8 @@ const getExternalOrganisationIds = async ( * Returns a list of irrigation prescriptions based for a specific farm. * * @param farmId - The ID of the farm to retrieve external irrigation prescriptions for. + * @param startTime - The 'after' date for filtering which irrigation prescriptions suggested start date will be irrigated. + * @param endTime - The 'before' date for filtering which irrigation prescriptions suggested start date will be irrigated. * @returns A promise that resolves to formatted irrigation prescription data. */ export const getIrrigationPrescriptions = async ( From b461a6aa891358e364167d0c6c4f163c14135772 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 16 May 2025 15:02:06 -0400 Subject: [PATCH 48/59] LF-4765 Refactor tests proper by creating facotries and mocking the external call --- .../api/tests/irrigation_prescription.test.ts | 156 ++++++++++-------- packages/api/tests/mock.factories.js | 59 +++++++ 2 files changed, 148 insertions(+), 67 deletions(-) diff --git a/packages/api/tests/irrigation_prescription.test.ts b/packages/api/tests/irrigation_prescription.test.ts index ace9878f5c..c3f7dfb2bd 100644 --- a/packages/api/tests/irrigation_prescription.test.ts +++ b/packages/api/tests/irrigation_prescription.test.ts @@ -13,6 +13,13 @@ * GNU General Public License for more details, see . */ +import { ensembleAPICall } from '../src/util/ensemble.js'; + +jest.mock('../src/util/ensemble.js', () => ({ + ...jest.requireActual('../src/util/ensemble.js'), + ensembleAPICall: jest.fn(async () => ({ data: [] })), // placeholder +})); + import chai from 'chai'; import chaiHttp from 'chai-http'; @@ -32,35 +39,15 @@ jest.mock('../src/middleware/acl/checkJwt.js', () => }), ); -import axios from 'axios'; -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - import { setupFarmEnvironment } from './utils/testDataSetup.js'; -import { connectFarmToEnsemble, fakeIrrigationPrescriptions } from './utils/ensembleUtils.js'; -import type { AddonPartner, Farm, IrrigationTask, User } from '../src/models/types.js'; +import { connectFarmToEnsemble } from './utils/ensembleUtils.js'; +import type { AddonPartner, Farm, User } from '../src/models/types.js'; import mocks from './mock.factories.js'; import { addDaysToDate, getEndOfDate, getStartOfDate } from './utils/date.js'; import { ENSEMBLE_BRAND } from '../src/util/ensemble.js'; describe('Get Irrigation Prescription Tests', () => { let ESciAddonPartner: AddonPartner; - async function postIrrigationTask({ - farm_id, - user_id, - data, - }: { - farm_id: Farm['farm_id']; - user_id: User['user_id']; - data: Partial; - }) { - return chai - .request(server) - .post(`/task/irrigation_task`) - .set('user_id', user_id) - .set('farm_id', farm_id) - .send(data); - } async function getIrrigationPrescription({ farm_id, @@ -71,9 +58,9 @@ describe('Get Irrigation Prescription Tests', () => { }: { farm_id: Farm['farm_id']; user_id: User['user_id']; - startTime?: string; - endTime?: string; - shouldSend?: string; + startTime: string; + endTime: string; + shouldSend: string; }): Promise { return chai .request(server) @@ -84,8 +71,17 @@ describe('Get Irrigation Prescription Tests', () => { .query({ startTime, endTime, shouldSend }); } + function removeUndefined>(arr: T[]): Partial[] { + return arr.map( + (obj) => + Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined), + ) as Partial, + ); + } + beforeEach(async () => { - (mockedAxios as unknown as jest.Mock).mockClear(); + jest.clearAllMocks(); await mocks.populateTaskTypes(); [ESciAddonPartner] = await mocks.addon_partnerFactory({ name: ENSEMBLE_BRAND, id: 1 }); }); @@ -100,59 +96,85 @@ describe('Get Irrigation Prescription Tests', () => { describe('All users should be able to GET irrigation prescription', () => { [1, 2, 3, 5].forEach((role) => { - test(`User with role ${role} should request IPs`, async () => { - const MOCK_EXTERNAL_PRESCRIPTION_ID1 = 1; - const MOCK_EXTERNAL_PRESCRIPTION_ID2 = 2; - const [taskTypeInDb] = await knex('task_type').where({ - farm_id: null, - task_translation_key: 'IRRIGATION_TASK', - }); + test(`2 User with role ${role} should request IPs`, async () => { + // Farm setup const { farm, field, user } = await setupFarmEnvironment(role); - const { farmAddon } = await connectFarmToEnsemble(farm, ESciAddonPartner); + await connectFarmToEnsemble(farm, ESciAddonPartner); + + // Just check linking of one task + const ipConfig = [ + { id: 1, linkToTask: true }, + { id: 2, linkToTask: false }, + ]; + + // Mock data for external endpoint + const externalIrrigationPrescriptions = await Promise.all( + ipConfig.map(async (config) => + mocks.externalIrrigationPrescriptionFactory({ + id: config.id, + providedFarm: farm, + providedLocation: field, + }), + ), + ); + + expect(externalIrrigationPrescriptions.length).toBe(2); + expect(externalIrrigationPrescriptions[0].location_id).toBe(field.location_id); + + // Mock response from our endpoint including formatting + const irrigationPrescriptions = await Promise.all( + externalIrrigationPrescriptions.map(async (externalIrrigationPrescription) => + mocks.irrigationPrescriptionFactory({ + providedExternalIrrigationPrescription: externalIrrigationPrescription, + providedPartner: ESciAddonPartner, + linkToTask: false, + }), + ), + ); + + expect(irrigationPrescriptions.length).toBe(2); + expect(irrigationPrescriptions[0].partner_id).toBe(ESciAddonPartner.id); - // Make one task for prescription 1 - await postIrrigationTask({ + // Call our endpoint and mock external call + const mockedEnsembleAPICall = ensembleAPICall as jest.Mock; + mockedEnsembleAPICall.mockResolvedValueOnce({ data: externalIrrigationPrescriptions }); + const res = await getIrrigationPrescription({ farm_id: farm.farm_id, user_id: user.user_id, - data: mocks.fakeTask({ - locations: [{ location_id: field.location_id }], - task_type_id: taskTypeInDb.task_type_id, - irrigation_task: mocks.fakeIrrigationTask({ - location_id: field.location_id, - irrigation_prescription_external_id: MOCK_EXTERNAL_PRESCRIPTION_ID1, - }), - }), + startTime: getStartOfDate(new Date()).toISOString(), + endTime: getEndOfDate(addDaysToDate(new Date(), 1)).toISOString(), + shouldSend: 'true', }); - const irrigationPrescriptions = await fakeIrrigationPrescriptions({ - farmId: farm.farm_id, - prescriptionIds: [MOCK_EXTERNAL_PRESCRIPTION_ID1, MOCK_EXTERNAL_PRESCRIPTION_ID2], - }); + expect(res.body).toMatchObject(removeUndefined(irrigationPrescriptions)); + + // Mock our endpoint if linking a task + const irrigationPrescriptionsWithTasks = await Promise.all( + externalIrrigationPrescriptions.map(async (externalIrrigationPrescription, index) => + mocks.irrigationPrescriptionFactory({ + providedExternalIrrigationPrescription: externalIrrigationPrescription, + providedPartner: ESciAddonPartner, + linkToTask: ipConfig[index].linkToTask, + providedFarm: farm, + providedLocation: field, + }), + ), + ); - expect(irrigationPrescriptions.length).toBe(2); - expect(irrigationPrescriptions[0].location_id).toBe(field.location_id); - - (mockedAxios as unknown as jest.Mock).mockResolvedValue({ - data: irrigationPrescriptions, - status: 200, - statusText: 'OK', - headers: {}, - config: {}, - }); + expect(irrigationPrescriptionsWithTasks.length).toBe(2); + expect(irrigationPrescriptionsWithTasks[0].task_id).toBeTruthy(); - await getIrrigationPrescription({ + // Re-call our endpoint and mock external call now seeing tasks populated + mockedEnsembleAPICall.mockResolvedValueOnce({ data: irrigationPrescriptionsWithTasks }); + const res2 = await getIrrigationPrescription({ farm_id: farm.farm_id, user_id: user.user_id, + startTime: getStartOfDate(new Date()).toISOString(), + endTime: getEndOfDate(addDaysToDate(new Date(), 1)).toISOString(), + shouldSend: 'true', }); - expect(axios).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'get', - url: expect.stringContaining( - `/organizations/${farmAddon.org_pk}/irrigation_prescriptions`, // real URL here - ), - }), - ); + expect(res2.body).toMatchObject(removeUndefined(irrigationPrescriptionsWithTasks)); }); }); }); diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index c2e63b4def..c1072853c0 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -2580,6 +2580,63 @@ async function farm_addonFactory({ .returning('*'); } +// Abnormal factory for external endpoint +export const externalIrrigationPrescriptionFactory = async ({ + id, + providedFarm, + providedLocation, + providedManagementPlan = null, +}) => { + const farm = providedFarm ?? farmFactory(); + const location = + providedLocation ?? + (await locationFactory({ promisedFarm: Promise.resolve(farm) ?? undefined })); + const managementPlan = + providedManagementPlan ?? + (await management_planFactory({ promisedFarm: Promise.resolve([farm]) ?? undefined })); + + return { + id: id ?? 1, + location_id: location.location_id, + management_plan_id: managementPlan.management_plan_id, + recommended_start_datetime: new Date().toISOString(), + }; +}; + +// Abnormal factory for external endpoint +export const irrigationPrescriptionFactory = async ({ + providedExternalIrrigationPrescription, + providedPartner, + linkToTask = false, + providedFarm = {}, + providedLocation = {}, + providedIrrigationTask = null, +}) => { + const externalIrrigationPrescription = + providedExternalIrrigationPrescription ?? (await externalIrrigationPrescriptionFactory({})); + const addonPartner = providedPartner ?? (await addon_partnerFactory()); + + const mockIrrigationTask = fakeIrrigationTask({ + location_id: externalIrrigationPrescription.location_id, + irrigation_prescription_external_id: externalIrrigationPrescription.id, + }); + + let irrigationTask; + if (providedIrrigationTask) { + irrigationTask = providedIrrigationTask; + } else if (linkToTask && !providedIrrigationTask) { + const task = await taskFactory({ promisedFarm: [providedFarm] }); + await location_tasksFactory({ promisedTask: task, promisedField: [providedLocation] }); + [irrigationTask] = await irrigation_taskFactory({ promisedTask: task }, mockIrrigationTask); + } + + return { + ...externalIrrigationPrescription, + partner_id: addonPartner.id, + task_id: irrigationTask?.task_id, + }; +}; + export default { weather_stationFactory, fakeStation, @@ -2742,5 +2799,7 @@ export default { animal_type_use_relationshipFactory, addon_partnerFactory, farm_addonFactory, + externalIrrigationPrescriptionFactory, + irrigationPrescriptionFactory, baseProperties, }; From 7dae07437320ed8eb87f5093f08bf2333637b912 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 16 May 2025 15:35:16 -0400 Subject: [PATCH 49/59] LF-4765 Make required query params in controller move type owner --- .../controllers/irrigationPrescriptionController.ts | 12 +++++------- .../validation/checkIrrigationPrescription.ts | 7 ++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index 2923660006..13526e779c 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -19,12 +19,7 @@ import ESciAddon from '../util/ensembleService.js'; import { fakeIrrigationPrescriptions } from '../../tests/utils/ensembleUtils.js'; import FarmAddonModel from '../models/farmAddonModel.js'; import { AddonFunctions, IrrigationPrescription } from '../util/ensembleService.types.js'; - -export interface IrrigationPrescriptionQueryParams { - startTime?: string; - endTime?: string; - shouldSend?: string; -} +import { IrrigationPrescriptionQueryParams } from '../middleware/validation/checkIrrigationPrescription.js'; // TODO: LF-4710 - Delete partner_id = 0, remove Partial const PARTNER_ID_MAP: Record> = { @@ -34,7 +29,10 @@ const PARTNER_ID_MAP: Record> = { const irrigationPrescriptionController = { getPrescriptions() { - return async (req: LiteFarmRequest, res: Response) => { + return async ( + req: LiteFarmRequest>, + res: Response, + ) => { try { const { farm_id } = req.headers; const { startTime, endTime, shouldSend } = req.query; diff --git a/packages/api/src/middleware/validation/checkIrrigationPrescription.ts b/packages/api/src/middleware/validation/checkIrrigationPrescription.ts index 161d60b6d1..b4ff5dd040 100644 --- a/packages/api/src/middleware/validation/checkIrrigationPrescription.ts +++ b/packages/api/src/middleware/validation/checkIrrigationPrescription.ts @@ -15,7 +15,12 @@ import { Request, Response, NextFunction } from 'express'; import { isISO8601Format } from '../../util/validation.js'; -import { IrrigationPrescriptionQueryParams } from '../../controllers/irrigationPrescriptionController.js'; + +export interface IrrigationPrescriptionQueryParams { + startTime?: string; + endTime?: string; + shouldSend?: string; +} export function checkGetIrrigationPrescription() { return async ( From 86cbd5cfea193c9f3251bf08c53801807c2febc0 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 16 May 2025 15:35:48 -0400 Subject: [PATCH 50/59] LF-4765 Add query params to frontend --- packages/webapp/src/store/api/apiSlice.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/webapp/src/store/api/apiSlice.ts b/packages/webapp/src/store/api/apiSlice.ts index fb6e2f5538..b2b10829ad 100644 --- a/packages/webapp/src/store/api/apiSlice.ts +++ b/packages/webapp/src/store/api/apiSlice.ts @@ -301,7 +301,8 @@ export const api = createApi({ const today = new Date(); const startTime = getStartOfDate(today).toISOString(); const endTime = getEndOfDate(addDaysToDate(today, 1)).toISOString(); - const params = new URLSearchParams({ startTime, endTime }); + const shouldSend = 'false'; + const params = new URLSearchParams({ startTime, endTime, shouldSend }); return `${irrigationPrescriptionUrl}?${params.toString()}`; }, From 66b0f86961368bbddd460ccb8174358c81a69353 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 16 May 2025 15:36:47 -0400 Subject: [PATCH 51/59] LF-4765 Refactor mocking --- packages/api/tests/utils/ensembleUtils.ts | 72 ++++++++--------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index a1848125c7..3930549654 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -17,7 +17,8 @@ import mocks from '../mock.factories.js'; import { ENSEMBLE_BRAND } from '../../src/util/ensemble.js'; import { IrrigationPrescription } from '../../src/util/ensembleService.types.js'; import TaskModel from '../../src/models/taskModel.js'; -import { AddonPartner, Farm, Location, ManagementPlan } from '../../src/models/types.js'; +import { AddonPartner, Farm } from '../../src/models/types.js'; +import LocationModel from '../../src/models/locationModel.js'; export const connectFarmToEnsemble = async (farm: Farm, partner?: AddonPartner) => { const [farmAddon] = await mocks.farm_addonFactory({ @@ -32,11 +33,8 @@ export const connectFarmToEnsemble = async (farm: Farm, partner?: AddonPartner) type fakeIrrigationPrescriptionsProps = { farmId: Farm['farm_id']; - prescriptionIds?: IrrigationPrescription['id'][]; - locationIds?: Location['location_id'][]; - managementPlanIds?: ManagementPlan['management_plan_id'][]; - startTime?: string; - endTime?: string; + startTime: string; + endTime: string; }; /** @@ -48,56 +46,32 @@ type fakeIrrigationPrescriptionsProps = { */ export const fakeIrrigationPrescriptions = async ({ farmId, - prescriptionIds = [1, 2], - locationIds, - managementPlanIds, startTime, endTime, }: fakeIrrigationPrescriptionsProps): Promise => { const PARTNER_ID = 1; - const ONE_HOUR_IN_MS = 1000 * 60 * 60; - const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; + const PRESCRIPTION_CONFIG = [ + { id: 1, recommendedDate: new Date(startTime) }, + { id: 2, recommendedDate: new Date(endTime) }, + ]; + const locations = await LocationModel.getCropSupportingLocationsByFarmId(farmId); + if (!locations.length) { + return []; + } const irrigationTasksWithExternalId = await TaskModel.getIrrigationTasksWithExternalIdByFarm( farmId, - prescriptionIds, - ); - - const irrigationTask1 = irrigationTasksWithExternalId.find( - (task) => task.irrigation_task.irrigation_prescription_external_id === prescriptionIds[0], - ); - const irrigationTask2 = irrigationTasksWithExternalId.find( - (task) => task.irrigation_task.irrigation_prescription_external_id === prescriptionIds[1], + PRESCRIPTION_CONFIG.map(({ id }) => id), ); - const locationId1 = irrigationTask1?.irrigation_task?.location_id ?? locationIds?.[0]; - const locationId2 = - irrigationTask2?.irrigation_task.location_id ?? locationIds?.[1] ?? locationId1; - - if (!locationId1 || !locationId2) { - return []; - } - - return [ - { - id: prescriptionIds[0], - location_id: locationId1, - management_plan_id: managementPlanIds?.[0] || undefined, - recommended_start_datetime: startTime - ? startTime - : new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(), - partner_id: PARTNER_ID, - task_id: irrigationTask1?.task_id, - }, - { - id: prescriptionIds[1], - location_id: locationId2, - management_plan_id: managementPlanIds?.[1] || undefined, - recommended_start_datetime: endTime - ? endTime - : new Date(Date.now() + ONE_DAY_IN_MS).toISOString(), - partner_id: PARTNER_ID, - task_id: irrigationTask1?.task_id, - }, - ]; + return PRESCRIPTION_CONFIG.map(({ id, recommendedDate }) => ({ + id, + location_id: locations[0].location_id, + management_plan_id: undefined, + recommended_start_datetime: recommendedDate.toISOString(), + partner_id: PARTNER_ID, + task_id: irrigationTasksWithExternalId.find( + (task) => task.irrigation_task.irrigation_prescription_external_id === id, + )?.task_id, + })); }; From 78d0cc3db3d3f33b4875a7677d3fb4ee4c83c3f0 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 20 May 2025 10:02:28 -0400 Subject: [PATCH 52/59] LF-4765 undo type casting --- packages/api/tests/utils/testDataSetup.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/api/tests/utils/testDataSetup.ts b/packages/api/tests/utils/testDataSetup.ts index 94e4ca4e25..6aa241b510 100644 --- a/packages/api/tests/utils/testDataSetup.ts +++ b/packages/api/tests/utils/testDataSetup.ts @@ -27,7 +27,7 @@ export function fakeUserFarm(role: number = 1) { /** * Creates a farm and a user, then associates them using the given role. */ -export async function returnUserFarms(role: number) { +export async function returnUserFarms(role: number): Promise<{ mainFarm: Farm; user: User }> { const [mainFarm] = await mocks.farmFactory(); const [user] = await mocks.usersFactory(); @@ -45,10 +45,7 @@ export async function returnUserFarms(role: number) { * Sets up the farm environment by creating a farm, owner, field, and (optionally) a non-owner user (if role id is provided) */ export async function setupFarmEnvironment(role: number = 1) { - const { mainFarm: farm, user: owner } = (await returnUserFarms(1)) as { - mainFarm: Farm; - user: User; - }; + const { mainFarm: farm, user: owner } = await returnUserFarms(1); let user = owner; if (role !== 1) { From 7207d696f2f21d6cd5772e59a6895e9fc5ea3068 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 20 May 2025 10:43:58 -0400 Subject: [PATCH 53/59] LF-4765 Invalidate tags on task deletion and creation --- packages/webapp/src/containers/Task/saga.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/webapp/src/containers/Task/saga.js b/packages/webapp/src/containers/Task/saga.js index f6374eed6b..728501a00f 100644 --- a/packages/webapp/src/containers/Task/saga.js +++ b/packages/webapp/src/containers/Task/saga.js @@ -612,6 +612,9 @@ export function* createTaskSaga({ payload }) { const { task_id, taskType } = task_translation_key === 'HARVEST_TASK' ? result.data[0] : result.data; yield call(getTasksSuccessSaga, { payload: isHarvest ? result.data : [result.data] }); + if (task_translation_key === 'IRRIGATION_TASK') { + yield put(api.util.invalidateTags(['IrrigationPrescriptions'])); + } if (alreadyCompleted) { const isCustomTaskWithAnimals = isCustomTask && (result.data.animals?.length || result.data.animal_batches?.length); @@ -970,6 +973,9 @@ export function* deleteTaskSaga({ payload: data }) { yield put(deleteTransplantTaskSuccess(result.data.task_id)); } yield put(deleteTaskSuccess(result.data)); + if (task_type.task_translation_key === 'IRRIGATION_TASK') { + yield put(api.util.invalidateTags(['IrrigationPrescriptions'])); + } yield put(enqueueSuccessSnackbar(i18n.t('TASK.DELETE.SUCCESS'))); } } catch (e) { From 1e530aef067a2ee251174f44f1491900fda69d07 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 20 May 2025 10:53:21 -0400 Subject: [PATCH 54/59] LF-4765 Plural url form --- packages/api/src/server.ts | 2 +- packages/api/tests/irrigation_prescription.test.ts | 2 +- packages/webapp/src/apiConfig.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 7d7a6d3973..71c8357c92 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -342,7 +342,7 @@ app .use('/notification_user', notificationUserRoute) .use('/time_notification', timeNotificationRoute) .use('/farm_addon', farmAddonRoute) - .use('/irrigation_prescription', irrigationPrescriptionRoute) + .use('/irrigation_prescriptions', irrigationPrescriptionRoute) .use('/irrigation_prescription_request', irrigationPrescriptionRequestRoute); // Allow a 1MB limit on sensors to match incoming Ensemble data diff --git a/packages/api/tests/irrigation_prescription.test.ts b/packages/api/tests/irrigation_prescription.test.ts index c3f7dfb2bd..03f0fed2c6 100644 --- a/packages/api/tests/irrigation_prescription.test.ts +++ b/packages/api/tests/irrigation_prescription.test.ts @@ -64,7 +64,7 @@ describe('Get Irrigation Prescription Tests', () => { }): Promise { return chai .request(server) - .get('/irrigation_prescription') + .get('/irrigation_prescriptions') .set('content-type', 'application/json') .set('farm_id', farm_id) .set('user_id', user_id) diff --git a/packages/webapp/src/apiConfig.js b/packages/webapp/src/apiConfig.js index 9be8b3f653..97ccb4fe4c 100644 --- a/packages/webapp/src/apiConfig.js +++ b/packages/webapp/src/apiConfig.js @@ -92,7 +92,7 @@ export const soilAmendmentPurposesUrl = `${URI}/soil_amendment_purposes`; export const soilAmendmentFertiliserTypesUrl = `${URI}/soil_amendment_fertiliser_types`; export const productUrl = `${URI}/product`; export const farmAddonUrl = `${URI}/farm_addon`; -export const irrigationPrescriptionUrl = `${URI}/irrigation_prescription`; +export const irrigationPrescriptionUrl = `${URI}/irrigation_prescriptions`; export const url = URI; From 4871eb3ef07cdc59c2ac6dbcc8b3050c0f86437d Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 20 May 2025 11:28:18 -0400 Subject: [PATCH 55/59] LF-4765 Add addonPartner service --- .../irrigationPrescriptionController.ts | 44 +----------- packages/api/src/services/addonPartner.ts | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 42 deletions(-) create mode 100644 packages/api/src/services/addonPartner.ts diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index 13526e779c..74ad427338 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -15,17 +15,9 @@ import { Response } from 'express'; import { LiteFarmRequest, HttpError } from '../types.js'; -import ESciAddon from '../util/ensembleService.js'; import { fakeIrrigationPrescriptions } from '../../tests/utils/ensembleUtils.js'; -import FarmAddonModel from '../models/farmAddonModel.js'; -import { AddonFunctions, IrrigationPrescription } from '../util/ensembleService.types.js'; import { IrrigationPrescriptionQueryParams } from '../middleware/validation/checkIrrigationPrescription.js'; - -// TODO: LF-4710 - Delete partner_id = 0, remove Partial -const PARTNER_ID_MAP: Record> = { - 0: {}, - 1: ESciAddon, -}; +import { getAddonPartnerIrrigationPrescriptions } from '../services/addonPartner.js'; const irrigationPrescriptionController = { getPrescriptions() { @@ -36,42 +28,10 @@ const irrigationPrescriptionController = { try { const { farm_id } = req.headers; const { startTime, endTime, shouldSend } = req.query; - const irrigationPrescriptions: IrrigationPrescription[] = []; - const partnerErrors: unknown[] = []; if (shouldSend === 'true') { - // Check for registered farm addons (only esci for now) // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - const farmAddonPartnerIds = await FarmAddonModel.getDistinctFarmAddonPartnerIds(farm_id); - - // Return empty array if no addons - if (!farmAddonPartnerIds.length) { - return res.status(200).send(irrigationPrescriptions); - } - - // Loop through addon partners - for (const farmAddonPartnerId of farmAddonPartnerIds) { - try { - const addonPartner = PARTNER_ID_MAP[farmAddonPartnerId.addon_partner_id]; - // TODO: LF-4710 - Skip deprecated partner_id = 0 situation - // Type guard for undefined functions - if (!addonPartner || typeof addonPartner.getIrrigationPrescriptions !== 'function') { - continue; - } - - irrigationPrescriptions.push( - // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - ...(await addonPartner.getIrrigationPrescriptions(farm_id, startTime, endTime)), - ); - } catch (error) { - partnerErrors.push(error); - } - } - - // Return an error if there are no prescriptions - if (!irrigationPrescriptions.length && partnerErrors.length) { - throw partnerErrors[0]; - } + const irrigationPrescriptions = await getAddonPartnerIrrigationPrescriptions(farm_id); return res.status(200).send(irrigationPrescriptions); } else { // Return data for dev purposes + QA diff --git a/packages/api/src/services/addonPartner.ts b/packages/api/src/services/addonPartner.ts new file mode 100644 index 0000000000..03ce3f97e6 --- /dev/null +++ b/packages/api/src/services/addonPartner.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import FarmAddonModel from '../models/farmAddonModel.js'; +import { Farm } from '../models/types.js'; +import ESciAddon from '../util/ensembleService.js'; +import { AddonFunctions, IrrigationPrescription } from '../util/ensembleService.types.js'; + +// TODO: LF-4710 - Delete partner_id = 0, remove Partial +const PARTNER_ID_MAP: Record> = { + 0: {}, + 1: ESciAddon, +}; + +export const getAddonPartnerIrrigationPrescriptions = async ( + farmId: Farm['farm_id'], + startTime: string, + endTime: string, +): Promise => { + const irrigationPrescriptions: IrrigationPrescription[] = []; + const partnerErrors: unknown[] = []; + + // Check for registered farm addons (only esci for now) + const farmAddonPartnerIds = await FarmAddonModel.getDistinctFarmAddonPartnerIds(farmId); + + // Return empty array if no addons + if (!farmAddonPartnerIds.length) { + return irrigationPrescriptions; + } + + // Loop through addon partners + for (const farmAddonPartnerId of farmAddonPartnerIds) { + try { + const addonPartner = PARTNER_ID_MAP[farmAddonPartnerId.addon_partner_id]; + // TODO: LF-4710 - Skip deprecated partner_id = 0 situation + // Type guard for undefined functions + if (!addonPartner || typeof addonPartner.getIrrigationPrescriptions !== 'function') { + continue; + } + + irrigationPrescriptions.push( + ...(await addonPartner.getIrrigationPrescriptions(farmId, startTime, endTime)), + ); + } catch (error) { + partnerErrors.push(error); + } + } + + // Return an error if there are no prescriptions, but there is an error + if (!irrigationPrescriptions.length && partnerErrors.length) { + throw partnerErrors[0]; + } + + return irrigationPrescriptions; +}; From 39172f8f2202a3d44fa600f77fdc70454feb8a76 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 20 May 2025 13:26:24 -0400 Subject: [PATCH 56/59] LF-4765 Move types and data formatting to service --- packages/api/src/services/addonPartner.ts | 58 +++++++++++++++++-- packages/api/src/util/ensembleService.ts | 32 +--------- .../api/src/util/ensembleService.types.ts | 8 --- 3 files changed, 54 insertions(+), 44 deletions(-) diff --git a/packages/api/src/services/addonPartner.ts b/packages/api/src/services/addonPartner.ts index 03ce3f97e6..685c271535 100644 --- a/packages/api/src/services/addonPartner.ts +++ b/packages/api/src/services/addonPartner.ts @@ -13,13 +13,19 @@ * GNU General Public License for more details, see . */ +import { AxiosResponse } from 'axios'; import FarmAddonModel from '../models/farmAddonModel.js'; +import TaskModel from '../models/taskModel.js'; import { Farm } from '../models/types.js'; +import { customError } from '../util/customErrors.js'; import ESciAddon from '../util/ensembleService.js'; -import { AddonFunctions, IrrigationPrescription } from '../util/ensembleService.types.js'; +import { + IrrigationPrescription, + isExternalIrrigationPrescriptionArray, +} from '../util/ensembleService.types.js'; // TODO: LF-4710 - Delete partner_id = 0, remove Partial -const PARTNER_ID_MAP: Record> = { +const PARTNER_ID_MAP: Record> = { 0: {}, 1: ESciAddon, }; @@ -50,9 +56,26 @@ export const getAddonPartnerIrrigationPrescriptions = async ( continue; } - irrigationPrescriptions.push( - ...(await addonPartner.getIrrigationPrescriptions(farmId, startTime, endTime)), - ); + const { data } = await addonPartner.getIrrigationPrescriptions(farmId, startTime, endTime); + + if (Array.isArray(data) && !data?.length) { + continue; + } + + if (!isExternalIrrigationPrescriptionArray(data)) { + throw customError( + `Partner id: ${farmAddonPartnerId} - irrigation prescription data not in expected format`, + ); + } + + // Add partner id to return object + const irrigationPrescriptionsWithPartnerId = data.map((irrigationPrescription) => ({ + ...irrigationPrescription, + partner_id: farmAddonPartnerId.addon_partner_id, + })); + + // Push prescriptions to return array + irrigationPrescriptions.push(...irrigationPrescriptionsWithPartnerId); } catch (error) { partnerErrors.push(error); } @@ -63,5 +86,28 @@ export const getAddonPartnerIrrigationPrescriptions = async ( throw partnerErrors[0]; } - return irrigationPrescriptions; + // Get irrigation tasks + const irrigationTasksWithExternalId = await TaskModel.getIrrigationTasksWithExternalIdByFarm( + farmId, + irrigationPrescriptions.map(({ id }) => id), + ); + + // Format response + const irrigationPrescriptionsWithTasks = irrigationPrescriptions.map((irrigationPrescription) => { + const foundTask = irrigationTasksWithExternalId.find( + (task) => + task.irrigation_task.irrigation_prescription_external_id === irrigationPrescription.id, + ); + return { ...irrigationPrescription, task_id: foundTask?.task_id }; + }); + + return irrigationPrescriptionsWithTasks; +}; + +type AddonPartnerFunctions = { + getIrrigationPrescriptions: ( + farmId: string, + startTime?: string, + endTime?: string, + ) => Promise>; }; diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts index 27f3322cb0..5cba3c1cad 100644 --- a/packages/api/src/util/ensembleService.ts +++ b/packages/api/src/util/ensembleService.ts @@ -28,10 +28,7 @@ import { type LocationAndCropGraph, type EnsembleLocationAndCropData, type ManagementPlan, - type IrrigationPrescription, - isExternalIrrigationPrescriptionArray, } from './ensembleService.types.js'; -import TaskModel from '../models/taskModel.js'; import { AddonPartner, Farm, FarmAddon } from '../models/types.js'; /** @@ -79,7 +76,7 @@ export const getIrrigationPrescriptions = async ( farmId: string, startTime?: string, endTime?: string, -): Promise => { +) => { // Get external organisation ids const addonPartnerId = await getAddonPartnerId(); const externalOrganizationIds = await getExternalOrganisationIds(farmId, addonPartnerId); @@ -102,32 +99,7 @@ export const getIrrigationPrescriptions = async ( }; // Get and check data - const { data } = await ensembleAPICall(axiosObject, onError); - - if (!data?.length) { - return []; - } - - if (!isExternalIrrigationPrescriptionArray(data)) { - throw customError(`${ENSEMBLE_BRAND} irrigation prescription data not in expected format`); - } - - // Get irrigation tasks - const irrigationTasksWithExternalId = await TaskModel.getIrrigationTasksWithExternalIdByFarm( - farmId, - data.map(({ id }) => id), - ); - - // Format response - const irrigationPrescriptions: IrrigationPrescription[] = data.map((irrigationPrescription) => { - const foundTask = irrigationTasksWithExternalId.find( - (task) => - task.irrigation_task.irrigation_prescription_external_id === irrigationPrescription.id, - ); - return { ...irrigationPrescription, partner_id: addonPartnerId, task_id: foundTask?.task_id }; - }); - - return irrigationPrescriptions; + return ensembleAPICall(axiosObject, onError); }; /** diff --git a/packages/api/src/util/ensembleService.types.ts b/packages/api/src/util/ensembleService.types.ts index 270cc63e53..44708a3121 100644 --- a/packages/api/src/util/ensembleService.types.ts +++ b/packages/api/src/util/ensembleService.types.ts @@ -120,11 +120,3 @@ export function isExternalIrrigationPrescriptionArray( }) ); } - -export interface AddonFunctions { - getIrrigationPrescriptions: ( - farmId: string, - startTime?: string, - endTime?: string, - ) => Promise; -} From 8d54eab8988795e957e7f7f0c9ffab09970e6fe1 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 20 May 2025 14:41:48 -0400 Subject: [PATCH 57/59] LF-4765 Move shouldSend boolean to only affect ESci --- .../irrigationPrescriptionController.ts | 22 ++++------ packages/api/src/services/addonPartner.ts | 20 ++++++--- packages/api/tests/utils/ensembleUtils.ts | 41 +++++++++++-------- 3 files changed, 47 insertions(+), 36 deletions(-) diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts index 74ad427338..dd8f68e212 100644 --- a/packages/api/src/controllers/irrigationPrescriptionController.ts +++ b/packages/api/src/controllers/irrigationPrescriptionController.ts @@ -15,7 +15,6 @@ import { Response } from 'express'; import { LiteFarmRequest, HttpError } from '../types.js'; -import { fakeIrrigationPrescriptions } from '../../tests/utils/ensembleUtils.js'; import { IrrigationPrescriptionQueryParams } from '../middleware/validation/checkIrrigationPrescription.js'; import { getAddonPartnerIrrigationPrescriptions } from '../services/addonPartner.js'; @@ -29,20 +28,15 @@ const irrigationPrescriptionController = { const { farm_id } = req.headers; const { startTime, endTime, shouldSend } = req.query; - if (shouldSend === 'true') { + const irrigationPrescriptions = await getAddonPartnerIrrigationPrescriptions( // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - const irrigationPrescriptions = await getAddonPartnerIrrigationPrescriptions(farm_id); - return res.status(200).send(irrigationPrescriptions); - } else { - // Return data for dev purposes + QA - const mockData = await fakeIrrigationPrescriptions({ - // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument - farmId: farm_id, - startTime, - endTime, - }); - return res.status(200).send(mockData); - } + farm_id, + startTime, + endTime, + shouldSend, + ); + + return res.status(200).send(irrigationPrescriptions); } catch (error: unknown) { console.error(error); const err = error as HttpError; diff --git a/packages/api/src/services/addonPartner.ts b/packages/api/src/services/addonPartner.ts index 685c271535..2f38e4c1ba 100644 --- a/packages/api/src/services/addonPartner.ts +++ b/packages/api/src/services/addonPartner.ts @@ -23,17 +23,23 @@ import { IrrigationPrescription, isExternalIrrigationPrescriptionArray, } from '../util/ensembleService.types.js'; +import { Mocks } from '../../tests/utils/ensembleUtils.js'; // TODO: LF-4710 - Delete partner_id = 0, remove Partial -const PARTNER_ID_MAP: Record> = { - 0: {}, - 1: ESciAddon, +const PARTNER_ID_MAP: Record AddonPartnerFunctions> = { + 0: () => { + return { getIrrigationPrescriptions: () => [] as unknown as Promise> }; + }, + 1: (shouldSend) => { + return shouldSend ? ESciAddon : Mocks; + }, }; export const getAddonPartnerIrrigationPrescriptions = async ( farmId: Farm['farm_id'], startTime: string, endTime: string, + shouldSend: boolean, ): Promise => { const irrigationPrescriptions: IrrigationPrescription[] = []; const partnerErrors: unknown[] = []; @@ -52,11 +58,15 @@ export const getAddonPartnerIrrigationPrescriptions = async ( const addonPartner = PARTNER_ID_MAP[farmAddonPartnerId.addon_partner_id]; // TODO: LF-4710 - Skip deprecated partner_id = 0 situation // Type guard for undefined functions - if (!addonPartner || typeof addonPartner.getIrrigationPrescriptions !== 'function') { + if (!addonPartner || typeof addonPartner().getIrrigationPrescriptions !== 'function') { continue; } - const { data } = await addonPartner.getIrrigationPrescriptions(farmId, startTime, endTime); + const { data } = await addonPartner(shouldSend).getIrrigationPrescriptions( + farmId, + startTime, + endTime, + ); if (Array.isArray(data) && !data?.length) { continue; diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts index 3930549654..777099477b 100644 --- a/packages/api/tests/utils/ensembleUtils.ts +++ b/packages/api/tests/utils/ensembleUtils.ts @@ -15,10 +15,11 @@ import mocks from '../mock.factories.js'; import { ENSEMBLE_BRAND } from '../../src/util/ensemble.js'; -import { IrrigationPrescription } from '../../src/util/ensembleService.types.js'; -import TaskModel from '../../src/models/taskModel.js'; +import { ExternalIrrigationPrescription } from '../../src/util/ensembleService.types.js'; import { AddonPartner, Farm } from '../../src/models/types.js'; import LocationModel from '../../src/models/locationModel.js'; +import { addDaysToDate, getEndOfDate, getStartOfDate } from './date.js'; +import { AxiosResponse } from 'axios'; export const connectFarmToEnsemble = async (farm: Farm, partner?: AddonPartner) => { const [farmAddon] = await mocks.farm_addonFactory({ @@ -33,8 +34,8 @@ export const connectFarmToEnsemble = async (farm: Farm, partner?: AddonPartner) type fakeIrrigationPrescriptionsProps = { farmId: Farm['farm_id']; - startTime: string; - endTime: string; + startTime?: string; + endTime?: string; }; /** @@ -44,34 +45,40 @@ type fakeIrrigationPrescriptionsProps = { * @param farm_id - The ID of the farm to retrieve mock data for. * @returns A promise that resolves to formatted irrigation prescription data. */ -export const fakeIrrigationPrescriptions = async ({ +export const getIrrigationPrescriptions = async ({ farmId, startTime, endTime, -}: fakeIrrigationPrescriptionsProps): Promise => { - const PARTNER_ID = 1; +}: fakeIrrigationPrescriptionsProps): Promise => { const PRESCRIPTION_CONFIG = [ - { id: 1, recommendedDate: new Date(startTime) }, - { id: 2, recommendedDate: new Date(endTime) }, + { + id: 1, + recommendedDate: startTime ? new Date(startTime) : getStartOfDate(new Date(Date.now())), + }, + { + id: 2, + recommendedDate: endTime + ? new Date(endTime) + : getEndOfDate(addDaysToDate(new Date(Date.now()), 1)), + }, ]; const locations = await LocationModel.getCropSupportingLocationsByFarmId(farmId); if (!locations.length) { return []; } - const irrigationTasksWithExternalId = await TaskModel.getIrrigationTasksWithExternalIdByFarm( - farmId, - PRESCRIPTION_CONFIG.map(({ id }) => id), - ); return PRESCRIPTION_CONFIG.map(({ id, recommendedDate }) => ({ id, location_id: locations[0].location_id, management_plan_id: undefined, recommended_start_datetime: recommendedDate.toISOString(), - partner_id: PARTNER_ID, - task_id: irrigationTasksWithExternalId.find( - (task) => task.irrigation_task.irrigation_prescription_external_id === id, - )?.task_id, })); }; + +export const Mocks = { + getIrrigationPrescriptions: async (farmId: string, startTime?: string, endTime?: string) => + ({ + data: await getIrrigationPrescriptions({ farmId, startTime, endTime }), + }) as unknown as AxiosResponse, +}; From 3a6219fa63ed79405bd05c3afbbef4510de53225 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 20 May 2025 14:48:47 -0400 Subject: [PATCH 58/59] LF-4765 rename mock --- packages/api/tests/irrigation_prescription.test.ts | 6 +++--- packages/api/tests/mock.factories.js | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/api/tests/irrigation_prescription.test.ts b/packages/api/tests/irrigation_prescription.test.ts index 03f0fed2c6..6b7764b3e4 100644 --- a/packages/api/tests/irrigation_prescription.test.ts +++ b/packages/api/tests/irrigation_prescription.test.ts @@ -110,7 +110,7 @@ describe('Get Irrigation Prescription Tests', () => { // Mock data for external endpoint const externalIrrigationPrescriptions = await Promise.all( ipConfig.map(async (config) => - mocks.externalIrrigationPrescriptionFactory({ + mocks.buildExternalIrrigationPrescription({ id: config.id, providedFarm: farm, providedLocation: field, @@ -124,7 +124,7 @@ describe('Get Irrigation Prescription Tests', () => { // Mock response from our endpoint including formatting const irrigationPrescriptions = await Promise.all( externalIrrigationPrescriptions.map(async (externalIrrigationPrescription) => - mocks.irrigationPrescriptionFactory({ + mocks.buildIrrigationPrescription({ providedExternalIrrigationPrescription: externalIrrigationPrescription, providedPartner: ESciAddonPartner, linkToTask: false, @@ -151,7 +151,7 @@ describe('Get Irrigation Prescription Tests', () => { // Mock our endpoint if linking a task const irrigationPrescriptionsWithTasks = await Promise.all( externalIrrigationPrescriptions.map(async (externalIrrigationPrescription, index) => - mocks.irrigationPrescriptionFactory({ + mocks.buildIrrigationPrescription({ providedExternalIrrigationPrescription: externalIrrigationPrescription, providedPartner: ESciAddonPartner, linkToTask: ipConfig[index].linkToTask, diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index c1072853c0..dd237dd2ee 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -2580,8 +2580,8 @@ async function farm_addonFactory({ .returning('*'); } -// Abnormal factory for external endpoint -export const externalIrrigationPrescriptionFactory = async ({ +// External endpoint helper mocks +export const buildExternalIrrigationPrescription = async ({ id, providedFarm, providedLocation, @@ -2603,8 +2603,7 @@ export const externalIrrigationPrescriptionFactory = async ({ }; }; -// Abnormal factory for external endpoint -export const irrigationPrescriptionFactory = async ({ +export const buildIrrigationPrescription = async ({ providedExternalIrrigationPrescription, providedPartner, linkToTask = false, @@ -2613,7 +2612,7 @@ export const irrigationPrescriptionFactory = async ({ providedIrrigationTask = null, }) => { const externalIrrigationPrescription = - providedExternalIrrigationPrescription ?? (await externalIrrigationPrescriptionFactory({})); + providedExternalIrrigationPrescription ?? (await buildExternalIrrigationPrescription({})); const addonPartner = providedPartner ?? (await addon_partnerFactory()); const mockIrrigationTask = fakeIrrigationTask({ @@ -2799,7 +2798,7 @@ export default { animal_type_use_relationshipFactory, addon_partnerFactory, farm_addonFactory, - externalIrrigationPrescriptionFactory, - irrigationPrescriptionFactory, + buildExternalIrrigationPrescription, + buildIrrigationPrescription, baseProperties, }; From 42b9df0a363948caf19bcd0f06574581681676d3 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 21 May 2025 19:02:18 -0400 Subject: [PATCH 59/59] LF-4765 Wrong type for shouldSend --- packages/api/src/services/addonPartner.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/src/services/addonPartner.ts b/packages/api/src/services/addonPartner.ts index 2f38e4c1ba..a4b4a9457e 100644 --- a/packages/api/src/services/addonPartner.ts +++ b/packages/api/src/services/addonPartner.ts @@ -26,12 +26,12 @@ import { import { Mocks } from '../../tests/utils/ensembleUtils.js'; // TODO: LF-4710 - Delete partner_id = 0, remove Partial -const PARTNER_ID_MAP: Record AddonPartnerFunctions> = { +const PARTNER_ID_MAP: Record AddonPartnerFunctions> = { 0: () => { return { getIrrigationPrescriptions: () => [] as unknown as Promise> }; }, 1: (shouldSend) => { - return shouldSend ? ESciAddon : Mocks; + return shouldSend === 'true' ? ESciAddon : Mocks; }, }; @@ -39,7 +39,7 @@ export const getAddonPartnerIrrigationPrescriptions = async ( farmId: Farm['farm_id'], startTime: string, endTime: string, - shouldSend: boolean, + shouldSend: string, ): Promise => { const irrigationPrescriptions: IrrigationPrescription[] = []; const partnerErrors: unknown[] = [];