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; +};