diff --git a/packages/api/src/controllers/irrigationPrescriptionController.ts b/packages/api/src/controllers/irrigationPrescriptionController.ts
new file mode 100644
index 0000000000..dd8f68e212
--- /dev/null
+++ b/packages/api/src/controllers/irrigationPrescriptionController.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 { IrrigationPrescriptionQueryParams } from '../middleware/validation/checkIrrigationPrescription.js';
+import { getAddonPartnerIrrigationPrescriptions } from '../services/addonPartner.js';
+
+const irrigationPrescriptionController = {
+ getPrescriptions() {
+ return async (
+ req: LiteFarmRequest>,
+ res: Response,
+ ) => {
+ try {
+ const { farm_id } = req.headers;
+ const { startTime, endTime, shouldSend } = req.query;
+
+ const irrigationPrescriptions = await getAddonPartnerIrrigationPrescriptions(
+ // @ts-expect-error - farm_id is guaranteed here by the checkScope middleware with single argument
+ farm_id,
+ startTime,
+ endTime,
+ shouldSend,
+ );
+
+ return res.status(200).send(irrigationPrescriptions);
+ } 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/controllers/irrigationPrescriptionRequestController.ts b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts
index 83014bd15f..4cf4fd859a 100644
--- a/packages/api/src/controllers/irrigationPrescriptionRequestController.ts
+++ b/packages/api/src/controllers/irrigationPrescriptionRequestController.ts
@@ -13,28 +13,26 @@
* GNU General Public License for more details, see .
*/
-import { Request, Response } from 'express';
+import { Response } from 'express';
import { getOrgLocationAndCropData, sendFieldAndCropDataToEsci } from '../util/ensembleService.js';
+import { LiteFarmRequest } from '../types.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 {
- headers: Request['headers'] & {
- farm_id?: string;
- };
-}
-
const irrigationPrescriptionRequestController = {
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/middleware/validation/checkIrrigationPrescription.ts b/packages/api/src/middleware/validation/checkIrrigationPrescription.ts
new file mode 100644
index 0000000000..b4ff5dd040
--- /dev/null
+++ b/packages/api/src/middleware/validation/checkIrrigationPrescription.ts
@@ -0,0 +1,47 @@
+/*
+ * 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';
+
+export interface IrrigationPrescriptionQueryParams {
+ startTime?: string;
+ endTime?: string;
+ shouldSend?: string;
+}
+
+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/models/farmAddonModel.js b/packages/api/src/models/farmAddonModel.js
index 1a6de7bac2..5251bf0a7b 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 unique addon partner ids from farm addons.
+ *
+ * @param {string} farmId - The ID of the farm.
+ * @returns {Promise>} The partner identifiers for the farm queried.
+ */
+ 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/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' },
diff --git a/packages/api/src/models/taskModel.js b/packages/api/src/models/taskModel.js
index 538ce6e219..5ba2f1c159 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,25 @@ class TaskModel extends BaseModel {
.withGraphFetched('[animals(selectId), animal_batches(selectId)]')
.whereIn('task_id', taskIds);
}
+
+ /**
+ * Returns farm tasks for an array of external ids
+ *
+ * @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[]} - Returns found irrigation tasks.
+ */
+ static async getIrrigationTasksWithExternalIdByFarm(farmId, externalIds) {
+ return await TaskModel.query()
+ .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();
+ }
}
export default TaskModel;
diff --git a/packages/api/src/models/types.ts b/packages/api/src/models/types.ts
new file mode 100644
index 0000000000..dc38b46922
--- /dev/null
+++ b/packages/api/src/models/types.ts
@@ -0,0 +1,596 @@
+/*
+ * 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 .
+ */
+
+/**
+ * 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 properties but a discriminated type (see IrrigationTask)
+ *
+ * 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.
+ *
+ * Once models can be converted to TS merge with the model file.
+ *
+ */
+
+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;
+}
+
+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 type Point = {
+ lat: number;
+ lng: number;
+};
+
+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;
+}
+
+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;
+}
+
+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;
+}
+
+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/routes/irrigationPrescriptionRoute.ts b/packages/api/src/routes/irrigationPrescriptionRoute.ts
new file mode 100644
index 0000000000..e895a0016c
--- /dev/null
+++ b/packages/api/src/routes/irrigationPrescriptionRoute.ts
@@ -0,0 +1,30 @@
+/*
+ * 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';
+import { checkGetIrrigationPrescription } from '../middleware/validation/checkIrrigationPrescription.js';
+
+const router = express.Router();
+
+router.get(
+ '/',
+ checkScope(['get:smart_irrigation']),
+ checkGetIrrigationPrescription(),
+ IrrigationPrescriptionController.getPrescriptions(),
+);
+
+export default router;
diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts
index 058d8ced60..e59e6b96ad 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_prescriptions', irrigationPrescriptionRoute)
.use('/irrigation_prescription_request', irrigationPrescriptionRequestRoute);
// Allow a 1MB limit on sensors to match incoming Ensemble data
diff --git a/packages/api/src/services/addonPartner.ts b/packages/api/src/services/addonPartner.ts
new file mode 100644
index 0000000000..a4b4a9457e
--- /dev/null
+++ b/packages/api/src/services/addonPartner.ts
@@ -0,0 +1,123 @@
+/*
+ * 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 { 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 {
+ 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 AddonPartnerFunctions> = {
+ 0: () => {
+ return { getIrrigationPrescriptions: () => [] as unknown as Promise> };
+ },
+ 1: (shouldSend) => {
+ return shouldSend === 'true' ? ESciAddon : Mocks;
+ },
+};
+
+export const getAddonPartnerIrrigationPrescriptions = async (
+ farmId: Farm['farm_id'],
+ startTime: string,
+ endTime: string,
+ shouldSend: 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;
+ }
+
+ const { data } = await addonPartner(shouldSend).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);
+ }
+ }
+
+ // Return an error if there are no prescriptions, but there is an error
+ if (!irrigationPrescriptions.length && partnerErrors.length) {
+ throw partnerErrors[0];
+ }
+
+ // 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/types.ts b/packages/api/src/types.ts
new file mode 100644
index 0000000000..8e1baa586a
--- /dev/null
+++ b/packages/api/src/types.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 {
+ 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;
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 b3da5a52a5..4f2a2f1fdc 100644
--- a/packages/api/src/util/ensembleService.ts
+++ b/packages/api/src/util/ensembleService.ts
@@ -23,12 +23,84 @@ 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,
+import {
+ type OrganisationFarmData,
+ type LocationAndCropGraph,
+ type EnsembleLocationAndCropData,
+ type ManagementPlan,
} from './ensembleService.types.js';
+import { AddonPartner, Farm, FarmAddon } from '../models/types.js';
+
+/**
+ * Retrieves the addon partner ID using a partners brand name.
+ *
+ * @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);
+ if (!partner) {
+ throw customError(`${ENSEMBLE_BRAND} partner not found`, 404);
+ }
+ return partner.id;
+};
+
+/**
+ * Retrieves the external organisation IDs for a specific farm and partner.
+ *
+ * @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 Not found error as we expect that the farms addon partner ids exist.
+ */
+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);
+ }
+ return farmAddonIds;
+};
+
+/**
+ * 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 (
+ farmId: string,
+ startTime?: string,
+ endTime?: string,
+) => {
+ // Get external organisation ids
+ const addonPartnerId = await getAddonPartnerId();
+ const externalOrganizationIds = await getExternalOrganisationIds(farmId, addonPartnerId);
+
+ // Endpoint config
+ const axiosObject = {
+ method: 'get',
+ url: `${ensembleAPI}/organizations/${externalOrganizationIds.org_pk}/irrigation_prescriptions`,
+ params: {
+ start_time: startTime, // ISO format
+ end_time: endTime, // ISO format
+ },
+ };
+
+ 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);
+ };
+
+ // Get and check data
+ return ensembleAPICall(axiosObject, onError);
+};
// TODO: After LF-4674 is merged, this can be removed and that function used instead
export const mockGetFarmIrrigationPrescriptions = async (farm_id: string) => {
@@ -231,3 +303,8 @@ export async function patchIrrigationPrescriptionApproval(id: number) {
throw error;
}
}
+
+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 ffd01bb3b2..44708a3121 100644
--- a/packages/api/src/util/ensembleService.types.ts
+++ b/packages/api/src/util/ensembleService.types.ts
@@ -13,10 +13,13 @@
* GNU General Public License for more details, see .
*/
-interface Point {
- lat: number;
- lng: number;
-}
+import {
+ AddonPartner,
+ Location,
+ Point,
+ Task,
+ ManagementPlan as ModelManagementPlan,
+} from '../models/types.js';
enum PlantingMethod {
BED_METHOD = 'bed_method',
@@ -83,3 +86,37 @@ export interface EnsembleLocationAndCropData {
export interface OrganisationFarmData {
[org_uuid: string]: EnsembleLocationAndCropData[];
}
+
+export type ExternalIrrigationPrescription = {
+ 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: AddonPartner['id'];
+ task_id?: Task['task_id'];
+}
+
+// Type guard for external endpoint
+// AI-assisted type guard
+export function isExternalIrrigationPrescriptionArray(
+ data: unknown,
+): data is ExternalIrrigationPrescription[] {
+ return (
+ Array.isArray(data) &&
+ data.every((item): item is ExternalIrrigationPrescription => {
+ if (typeof item !== 'object' || item === null) return false;
+
+ const obj = item as Record;
+
+ return (
+ 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'
+ );
+ })
+ );
+}
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})$/;
diff --git a/packages/api/tests/irrigation_prescription.test.ts b/packages/api/tests/irrigation_prescription.test.ts
new file mode 100644
index 0000000000..6b7764b3e4
--- /dev/null
+++ b/packages/api/tests/irrigation_prescription.test.ts
@@ -0,0 +1,181 @@
+/*
+ * 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 { 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';
+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 { setupFarmEnvironment } from './utils/testDataSetup.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 getIrrigationPrescription({
+ farm_id,
+ user_id,
+ startTime = getStartOfDate(new Date()).toISOString(),
+ endTime = getEndOfDate(addDaysToDate(new Date(), 1)).toISOString(),
+ shouldSend = 'true',
+ }: {
+ farm_id: Farm['farm_id'];
+ user_id: User['user_id'];
+ startTime: string;
+ endTime: string;
+ shouldSend: string;
+ }): Promise {
+ return chai
+ .request(server)
+ .get('/irrigation_prescriptions')
+ .set('content-type', 'application/json')
+ .set('farm_id', farm_id)
+ .set('user_id', user_id)
+ .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 () => {
+ jest.clearAllMocks();
+ await mocks.populateTaskTypes();
+ [ESciAddonPartner] = await mocks.addon_partnerFactory({ name: ENSEMBLE_BRAND, id: 1 });
+ });
+
+ 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(`2 User with role ${role} should request IPs`, async () => {
+ // Farm setup
+ const { farm, field, user } = await setupFarmEnvironment(role);
+ 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.buildExternalIrrigationPrescription({
+ 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.buildIrrigationPrescription({
+ providedExternalIrrigationPrescription: externalIrrigationPrescription,
+ providedPartner: ESciAddonPartner,
+ linkToTask: false,
+ }),
+ ),
+ );
+
+ expect(irrigationPrescriptions.length).toBe(2);
+ expect(irrigationPrescriptions[0].partner_id).toBe(ESciAddonPartner.id);
+
+ // 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,
+ startTime: getStartOfDate(new Date()).toISOString(),
+ endTime: getEndOfDate(addDaysToDate(new Date(), 1)).toISOString(),
+ shouldSend: 'true',
+ });
+
+ expect(res.body).toMatchObject(removeUndefined(irrigationPrescriptions));
+
+ // Mock our endpoint if linking a task
+ const irrigationPrescriptionsWithTasks = await Promise.all(
+ externalIrrigationPrescriptions.map(async (externalIrrigationPrescription, index) =>
+ mocks.buildIrrigationPrescription({
+ providedExternalIrrigationPrescription: externalIrrigationPrescription,
+ providedPartner: ESciAddonPartner,
+ linkToTask: ipConfig[index].linkToTask,
+ providedFarm: farm,
+ providedLocation: field,
+ }),
+ ),
+ );
+
+ expect(irrigationPrescriptionsWithTasks.length).toBe(2);
+ expect(irrigationPrescriptionsWithTasks[0].task_id).toBeTruthy();
+
+ // 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(res2.body).toMatchObject(removeUndefined(irrigationPrescriptionsWithTasks));
+ });
+ });
+ });
+});
diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js
index e2a896acd1..dd237dd2ee 100644
--- a/packages/api/tests/mock.factories.js
+++ b/packages/api/tests/mock.factories.js
@@ -2548,13 +2548,16 @@ async function animal_type_use_relationshipFactory({
.returning('*');
}
-async function addon_partnerFactory(partner = { name: faker.company.companyName() }) {
- const [existingPartner] = await knex('addon_partner').where({ name: partner.name });
+async function addon_partnerFactory(partner) {
+ const fakePartner = partner ? null : { name: faker.company.companyName() };
+ const [existingPartner] = await knex('addon_partner').where({
+ name: partner ? partner.name : fakePartner.name,
+ });
if (!existingPartner) {
return knex('addon_partner')
.insert({
- ...partner,
+ ...(partner ? partner : fakePartner),
access_token: faker.datatype.access_token,
refresh_token: faker.datatype.refresh_token,
})
@@ -2577,6 +2580,62 @@ async function farm_addonFactory({
.returning('*');
}
+// External endpoint helper mocks
+export const buildExternalIrrigationPrescription = 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(),
+ };
+};
+
+export const buildIrrigationPrescription = async ({
+ providedExternalIrrigationPrescription,
+ providedPartner,
+ linkToTask = false,
+ providedFarm = {},
+ providedLocation = {},
+ providedIrrigationTask = null,
+}) => {
+ const externalIrrigationPrescription =
+ providedExternalIrrigationPrescription ?? (await buildExternalIrrigationPrescription({}));
+ 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,
@@ -2739,5 +2798,7 @@ export default {
animal_type_use_relationshipFactory,
addon_partnerFactory,
farm_addonFactory,
+ buildExternalIrrigationPrescription,
+ buildIrrigationPrescription,
baseProperties,
};
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/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;
+}
diff --git a/packages/api/tests/utils/ensembleUtils.ts b/packages/api/tests/utils/ensembleUtils.ts
index fdbe399674..777099477b 100644
--- a/packages/api/tests/utils/ensembleUtils.ts
+++ b/packages/api/tests/utils/ensembleUtils.ts
@@ -15,16 +15,70 @@
import mocks from '../mock.factories.js';
import { ENSEMBLE_BRAND } from '../../src/util/ensemble.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 interface Farm {
- farm_id: string;
-}
-
-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 }),
});
return { farmAddon };
};
+
+type fakeIrrigationPrescriptionsProps = {
+ farmId: Farm['farm_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
+ *
+ * @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 getIrrigationPrescriptions = async ({
+ farmId,
+ startTime,
+ endTime,
+}: fakeIrrigationPrescriptionsProps): Promise => {
+ const PRESCRIPTION_CONFIG = [
+ {
+ 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 [];
+ }
+
+ return PRESCRIPTION_CONFIG.map(({ id, recommendedDate }) => ({
+ id,
+ location_id: locations[0].location_id,
+ management_plan_id: undefined,
+ recommended_start_datetime: recommendedDate.toISOString(),
+ }));
+};
+
+export const Mocks = {
+ getIrrigationPrescriptions: async (farmId: string, startTime?: string, endTime?: string) =>
+ ({
+ data: await getIrrigationPrescriptions({ farmId, startTime, endTime }),
+ }) as unknown as AxiosResponse,
+};
diff --git a/packages/api/tests/utils/testDataSetup.ts b/packages/api/tests/utils/testDataSetup.ts
index 9b57d22bbb..6aa241b510 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.
*/
@@ -38,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();
@@ -91,7 +80,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]) },
{
diff --git a/packages/webapp/src/apiConfig.js b/packages/webapp/src/apiConfig.js
index e689ba1735..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 irrigationPrescriptionsUrl = `${URI}/irrigation_prescriptions`;
+export const irrigationPrescriptionUrl = `${URI}/irrigation_prescriptions`;
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/containers/Task/saga.js b/packages/webapp/src/containers/Task/saga.js
index d1bdafb1a5..97da435f4b 100644
--- a/packages/webapp/src/containers/Task/saga.js
+++ b/packages/webapp/src/containers/Task/saga.js
@@ -647,6 +647,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);
@@ -1005,6 +1008,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) {
diff --git a/packages/webapp/src/store/api/apiSlice.ts b/packages/webapp/src/store/api/apiSlice.ts
index 6816cdc020..665edfb5ae 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,
@@ -111,6 +111,8 @@ export const FarmLibraryTags = [
// 'count' param returns farm specific data
'DefaultAnimalTypes',
];
+import { addDaysToDate } from '../../util/date';
+import { getEndOfDate, getStartOfDate } from '../../util/date-migrate-TS';
export const api = createApi({
baseQuery: fetchBaseQuery({
@@ -320,7 +322,16 @@ 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 today = new Date();
+ const startTime = getStartOfDate(today).toISOString();
+ const endTime = getEndOfDate(addDaysToDate(today, 1)).toISOString();
+ const shouldSend = 'false';
+ const params = new URLSearchParams({ startTime, endTime, shouldSend });
+
+ return `${irrigationPrescriptionUrl}?${params.toString()}`;
+ },
async onQueryStarted(_id, { dispatch, queryFulfilled }) {
try {
// TODO: Once tasks is migrated to rtk use invalidatesTags instead of onQueryStarted'
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;
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;
+};