Repository: LiteFarmOrg/LiteFarm
Pull request #3762: LF-4771 Create notification when new IP is available
Author: login: kathyavini
Labels: name: enhancement
description: New feature or request
name: new translations
description: New translations to be sent to CrowdIn are present
Created: 2025-05-06T23:26:18Z | Merged: 2025-05-13T21:58:45Z

**Description**

This adds irrigation prescription notifications, run daily at 5am _summer time,_ otherwise 4am, to the other notifications (= tasks due today, and weekly unassigned tasks, if applicable) that are run daily by our jobs scheduler.

**The PR includes:**
- Slight refactor of scheduler to make it extensible
  - I haven't changed any of the core logic, but only wrapped it in a loop so that we can add our 5am job (and in the future, more times and jobs if desired). Previously 6am + these the two original notification types were hardcoded
- The new API endpoint (route + controller) that is called by scheduler
  - the timeNotification API endpoints are a bit unique in that they are designed to be called ONLY via scheduler and not from the webapp
  - they have their own authentication including distinct headers and JWT so I don't think they can be called by Postman. I will describe testing below
- API tests for the new endpoint
- mock functions in the `ensembleService.ts` file that are named with "mock" and can be deleted entirely when the GET endpoint from #3755 is complete
  - In the tests I have mocked axios itself and I don't believe any changes should be necessary to keep the tests passing once the endpoint is complete


### Notes on the existing Time Notification Controller methods:
In looking at this flow I have fixed one small bug in the Daily Task Due Notifications, and added a test case for it. That should address this Jira ticket:  https://lite-farm.atlassian.net/browse/LF-3673. Source of bug is indicated below.

I'm not ready to definitively say it's an error/bug, but I did want to remark that there are two different patterns already in place for the Weekly Unassigned Tasks and the Daily Task Due notifications regarding the `notification` and `nofication_user` tables/models:

- Weekly Unassigned Tasks: 1) creates one record in the `notification` table, and 2) notifies all admin users (creates _n_ records in the `notification_user` table) that correspond to that one notification
- Daily Task Due: 1) Creates one record in the `notication` table per record created in the `notification_user` table. This is beause the Daily Task Due loops through users and calls the `.notify()` method of the model once per user. At first I thought it was a bug, but it may also be intentional in the sense that these notifications (has tasks due) in particular are unique per user?

In any case, for IP there I have followed the Weekly Unassigned Tasks pattern. So one `notification` record is created, and _n_ `notification_user` records will map to that one notification. There is nothing user-specific about the irrigation prescription notification so this seemed correct.

Jira link: https://lite-farm.atlassian.net/browse/LF-4771

**Type of change**

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

**How Has This Been Tested?**

- [ ] Passes test case
- [ ] UI components visually reviewed on desktop view
- [ ] UI components visually reviewed on mobile view
- [x] Other (please explain)

You can add the environment variable:

```
MOCK_TIMER=true
```

to `packages/api/.env` to use a very neat pre-existing testing functionality of the scheduler. Normally the scheduler runs on an hourly cronjob, but with that change, it will run every minute. This means if you run the scheduler in the background and wait at least 24 minutes, every farm you have will get its daily notifications.

Since the scheduler is shared with the export server (locally and in production); in dev use `docker compose up export` to run it. You can leave it running in the background while it makes its loop through all time zones.

If you're feeling wildly impatient though, search for this line:

```js
  driverQueue.add({}, { repeat: { cron: `${mockTimer ? '*' : '0'} * * * *` } });
```
and change it to

```js
  driverQueue.add({}, { repeat: { cron: `${mockTimer ? '* *' : '0'} * * * *` } });
```

to run the cronjob once every second. There will be a lot of console output though; be ready to stop it.

_**Actually I don't particularly recommend this**_ as it's easy to accidentally flood your bull queue and have the jobs still running for quite a while. I did have to manually clear my queue from Redis at one point after doing this.

**Checklist:**

- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] The precommit and linting ran successfully
- [ ] I have added or updated language tags for text that's part of the UI
- [ ] I have added "MISSING" for all new language tags to languages I don't speak
- [ ] I have added [the GNU General Public License](https://lite-farm.atlassian.net/l/cp/BT0Dd7WW) to all new files



Diff:
diff --git a/packages/api/src/controllers/timeNotificationController.js b/packages/api/src/controllers/timeNotificationController.js
index 7ba8ba92c0..e4b3b3def4 100644
--- a/packages/api/src/controllers/timeNotificationController.js
+++ b/packages/api/src/controllers/timeNotificationController.js
@@ -16,8 +16,10 @@
 import UserFarmModel from '../models/userFarmModel.js';
 
 import TaskModel from '../models/taskModel.js';
+import NotificationModel from '../models/notificationModel.js';
 import NotificationUser from '../models/notificationUserModel.js';
 import { getTasksForFarm } from './taskController.js';
+import { mockGetFarmIrrigationPrescriptions } from '../util/ensembleService.js';
 
 const timeNotificationController = {
   /**
@@ -83,7 +85,7 @@ const timeNotificationController = {
         );
 
         if (hasTasksDueToday) {
-          await sendDailyDueTodayTaskNotification(farm_id, user_id);
+          await sendDailyDueTodayTaskNotification(farm_id, user_id, isDayLaterThanUtc);
           notificationsSent++;
         }
       }
@@ -95,18 +97,72 @@ const timeNotificationController = {
       return res.status(400).send({ error });
     }
   },
+
+  /**
+   * Notifies all users of new irrigation prescriptions
+   * @param {Request} req request
+   * @param {Response} res response
+   * @async
+   */
+  async postDailyNewIrrigationPrescriptions(req, res) {
+    const { farm_id } = req.params;
+    const { isDayLaterThanUtc } = req.body;
+    try {
+      // TODO: Use real function after LF-4765 is merged
+      const farmIrrigationPrescriptions = await mockGetFarmIrrigationPrescriptions(farm_id);
+
+      let notificationsSent = 0;
+
+      const latestIrrigationPrescription = farmIrrigationPrescriptions.at(-1);
+
+      if (latestIrrigationPrescription) {
+        const { id: irrigation_prescription_id } = latestIrrigationPrescription;
+
+        const previousNotification = await NotificationModel.query()
+          .where('farm_id', farm_id)
+          .whereJsonSupersetOf('context', {
+            irrigation_prescription_id,
+          })
+          .whereNotDeleted()
+          .first();
+
+        if (!previousNotification) {
+          const activeUsers = await UserFarmModel.getActiveUsersFromFarmId(farm_id);
+
+          const userIds = activeUsers.map(({ user_id }) => user_id);
+
+          await sendDailyNewIrrigationPrescriptionNotification(
+            farm_id,
+            userIds,
+            isDayLaterThanUtc,
+            irrigation_prescription_id,
+          );
+          notificationsSent += userIds.length;
+        }
+      }
+
+      return res
+        .status(notificationsSent ? 201 : 200)
+        .send(`${notificationsSent} irrigation prescription notifications sent.`);
+    } catch (error) {
+      console.log(error);
+      return res.status(400).send({ error });
+    }
+  },
 };
 
 /**
  * Notifies farm management of unassigned tasks due this week.
  * @param {String} farmId - id of the farm the farm managers belong to
  * @param {Array} farmManagement - user_ids of FM/FO/EO that need to be notified
- * @param {String} firstTaskTranslationKey - task translation key of the first unassigned task
+ * @param {Boolean} isDayLaterThanUtc  - offset “today” by +1 day for UTC+ zones
  * @async
  */
 async function sendWeeklyUnassignedTaskNotifications(farmId, farmManagement, isDayLaterThanUtc) {
   const today = new Date();
-  if (isDayLaterThanUtc) today.setDate(today.getDate() + 1);
+  if (isDayLaterThanUtc) {
+    today.setDate(today.getDate() + 1);
+  }
   const todayStr = today.toISOString().split('T')[0];
   await NotificationUser.notify(
     {
@@ -129,11 +185,14 @@ async function sendWeeklyUnassignedTaskNotifications(farmId, farmManagement, isD
  * Sends notification to a user of tasks due today
  * @param {String} farmId
  * @param {String} userId
+ * @param {Boolean} isDayLaterThanUtc  - offset “today” by +1 day for UTC+ zones
  * @async
  */
 async function sendDailyDueTodayTaskNotification(farmId, userId, isDayLaterThanUtc) {
   const today = new Date();
-  if (isDayLaterThanUtc) today.setDate(today.getDate() + 1);
+  if (isDayLaterThanUtc) {
+    today.setDate(today.getDate() + 1);
+  }
   const todayStr = today.toISOString().split('T')[0];
   await NotificationUser.notify(
     {
@@ -152,4 +211,40 @@ async function sendDailyDueTodayTaskNotification(farmId, userId, isDayLaterThanU
   );
 }
 
+/**
+ * Sends notification to a user of a new irrigation prescription
+ * @param {String} farmId
+ * @param {String[]} userIds
+ * @param {Boolean} isDayLaterThanUtc  - offset “today” by +1 day for UTC+ zones
+ * @async
+ */
+async function sendDailyNewIrrigationPrescriptionNotification(
+  farmId,
+  userIds,
+  isDayLaterThanUtc,
+  irrigation_prescription_id,
+) {
+  const today = new Date();
+  if (isDayLaterThanUtc) {
+    today.setDate(today.getDate() + 1);
+  }
+  const todayStr = today.toISOString().split('T')[0];
+  await NotificationUser.notify(
+    {
+      title: { translation_key: 'NOTIFICATION.NEW_IRRIGATION_PRESCRIPTION.TITLE' },
+      body: { translation_key: 'NOTIFICATION.NEW_IRRIGATION_PRESCRIPTION.BODY' },
+      variables: [],
+      ref: { url: `/irrigation_prescription/${irrigation_prescription_id}` },
+      context: {
+        icon_translation_key: 'IRRIGATION_PRESCRIPTION',
+        notification_type: 'NEW_IRRIGATION_PRESCRIPTION',
+        notification_date: todayStr,
+        irrigation_prescription_id,
+      },
+      farm_id: farmId,
+    },
+    userIds,
+  );
+}
+
 export default timeNotificationController;
diff --git a/packages/api/src/jobs/notifications/index.js b/packages/api/src/jobs/notifications/index.js
index c019047509..007fe69310 100644
--- a/packages/api/src/jobs/notifications/index.js
+++ b/packages/api/src/jobs/notifications/index.js
@@ -43,66 +43,86 @@ let utcHour = mockTimer ? 5 : undefined; // mock starts 2 "hours" before day shi
 | Date offset    |  0,1   |  0,1   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 0   | 1   | 1   | 1   | 1   | 1   | 1   |
 */
 
+function getTimeZonesForLocalHour(utcHour, localHour) {
+  const timeZones = [localHour - utcHour];
+  if (timeZones[0] < -11) timeZones[0] += 24;
+  if (timeZones[0] === -10 || timeZones[0] === -11) timeZones[1] = timeZones[0] + 24;
+
+  return timeZones;
+}
+
+function isWeeklyNotificationDue(timeZone) {
+  return (
+    (utcDay === WEEKLY_NOTIFICATION_DAY_LATER_ZONES && timeZone < 7) ||
+    (utcDay === WEEKLY_NOTIFICATION_DAY_EARLIER_ZONES && timeZone >= 7)
+  );
+}
+
+const schedules = [
+  {
+    localHour: 5,
+    notificationTypes: [
+      { type: 'DailyTasksDue' },
+      { type: 'WeeklyUnassigned', checkRunCondition: isWeeklyNotificationDue },
+    ],
+  },
+  { localHour: 4, notificationTypes: [{ type: 'DailyNewIrrigationPrescription' }] },
+];
+
 const sendOnSchedule = (queueConfig) => {
   const driverQueue = new Queue('Schedule driver ', queueConfig);
   const apiQueue = new Queue('Notification API requests', queueConfig);
 
   // At the top of every hour ...
-  driverQueue.process((job, done) => {
+  driverQueue.process(async (_job, done) => {
     // ... clean completed and failed API requests that have been in queue for 1 day ...
     apiQueue.clean(ONE_DAY);
     apiQueue.clean(ONE_DAY, 'failed');
 
-    // ... find the UTC offsets where it just became 6am ...
     if (!mockTimer) {
       const now = new Date();
       utcDay = now.getUTCDay();
       utcHour = now.getUTCHours();
     }
-    const timeZones = [6 - utcHour];
-    if (timeZones[0] < -11) timeZones[0] += 24;
-    if (timeZones[0] === -10 || timeZones[0] === -11) timeZones[1] = timeZones[0] + 24;
-
-    console.log(
-      `Scheduled notifications: UTC day ${utcDay}, ${utcHour}:00. It's now 6am at UTC offset(s) ${timeZones}`,
-    );
 
-    // ... and call the API to get farms for those offsets.
     const token = sign({ requestTimedNotifications: true }, process.env.JWT_SCHEDULER_SECRET, {
       expiresIn: '1d',
       algorithm: 'HS256',
     });
 
-    for (const timeZone of timeZones) {
-      const start = 3600 * timeZone; // hours to seconds
-      const end = 3600 * (timeZone + 1) - 1;
-      console.log(`  offset range is ${start} to ${end} seconds`);
-
-      const reqConfig = { headers: { Authorization: `Bearer ${token}` } };
-      axios
-        .get(`${apiUrl}/farm/utc_offset_by_range/${start}/${end}`, reqConfig)
-        .then(function (res) {
-          // For each farm ...
-          for (const farmId of res.data) {
-            // ... send daily 6am notifications ...
+    const reqConfig = { headers: { Authorization: `Bearer ${token}` } };
+
+    for (const { localHour, notificationTypes } of schedules) {
+      // ... find the UTC offsets where it just became the specified local hour ...
+      const timeZones = getTimeZonesForLocalHour(utcHour, localHour);
+      console.log(
+        `Scheduled notifications: UTC day ${utcDay}, ${utcHour}:00. It's now ${localHour}am at UTC offset(s) ${timeZones}`,
+      );
+
+      // ... and call the API to get farms for those offsets.
+      for (const timeZone of timeZones) {
+        const start = 3600 * timeZone;
+        const end = 3600 * (timeZone + 1) - 1;
+        console.log(`  offset range is ${start} to ${end} seconds`);
+
+        const res = await axios.get(
+          `${apiUrl}/farm/utc_offset_by_range/${start}/${end}`,
+          reqConfig,
+        );
+
+        for (const farmId of res.data) {
+          for (const { type, checkRunCondition } of notificationTypes) {
+            if (checkRunCondition && !checkRunCondition(timeZone)) continue;
+
             apiQueue.add(
-              { farmId, type: 'Daily', reqConfig, isDayLaterThanUtc: timeZone >= 7 },
-              { jobId: `Daily-${utcDay}-${utcHour}-${farmId}` },
+              { farmId, type, reqConfig, isDayLaterThanUtc: timeZone >= 7 },
+              { jobId: `${type}-${utcDay}-${utcHour}-${farmId}` },
             );
-
-            // ... and weekly 6am notifications if appropriate.
-            if (
-              (utcDay === WEEKLY_NOTIFICATION_DAY_LATER_ZONES && timeZone < 7) ||
-              (utcDay === WEEKLY_NOTIFICATION_DAY_EARLIER_ZONES && timeZone >= 7)
-            ) {
-              apiQueue.add(
-                { farmId, type: 'Weekly', reqConfig, isDayLaterThanUtc: timeZone >= 7 },
-                { jobId: `Weekly-${utcDay}-${utcHour}-${farmId}` },
-              );
-            }
           }
-        });
+        }
+      }
     }
+
     if (mockTimer && ++utcHour === 24) {
       utcHour = 0;
       utcDay = ++utcDay % 7;
@@ -119,8 +139,9 @@ const sendOnSchedule = (queueConfig) => {
   apiQueue.process((job, done) => {
     const { type, farmId, reqConfig, isDayLaterThanUtc } = job.data;
     const urls = {
-      Daily: 'time_notification/daily_due_today_tasks',
-      Weekly: 'time_notification/weekly_unassigned_tasks',
+      DailyTasksDue: 'time_notification/daily_due_today_tasks',
+      WeeklyUnassigned: 'time_notification/weekly_unassigned_tasks',
+      DailyNewIrrigationPrescription: 'time_notification/new_irrigation_prescription',
     };
     axios
       .post(`${apiUrl}/${urls[type]}/${farmId}`, { isDayLaterThanUtc }, reqConfig)
diff --git a/packages/api/src/middleware/validation/checkTask.js b/packages/api/src/middleware/validation/checkTask.js
index 106a718058..64c7e3a48b 100644
--- a/packages/api/src/middleware/validation/checkTask.js
+++ b/packages/api/src/middleware/validation/checkTask.js
@@ -304,11 +304,14 @@ async function checkIrrigationTask(req) {
   const esciExternalId = req.body?.irrigation_task?.irrigation_prescription_external_id;
 
   if (esciExternalId) {
+    const { farm_id } = req.headers;
     const existing = await TaskModel.query()
+      .whereNotDeleted()
       .joinRelated('irrigation_task')
+      .join('location', 'irrigation_task.location_id', 'location.location_id')
       .select('task.*')
       .where('irrigation_task.irrigation_prescription_external_id', esciExternalId)
-      .whereNotDeleted()
+      .andWhere('location.farm_id', farm_id)
       .first();
 
     if (existing) {
diff --git a/packages/api/src/routes/timeNotificationRoute.js b/packages/api/src/routes/timeNotificationRoute.js
index 43c3f2d93a..2e575f4fec 100644
--- a/packages/api/src/routes/timeNotificationRoute.js
+++ b/packages/api/src/routes/timeNotificationRoute.js
@@ -34,4 +34,11 @@ router.post(
   timeNotificationController.postDailyDueTodayTasks,
 );
 
+router.post(
+  '/new_irrigation_prescription/:farm_id',
+  checkSchedulerJwt,
+  hasTimeNotificationsAccess,
+  timeNotificationController.postDailyNewIrrigationPrescriptions,
+);
+
 export default router;
diff --git a/packages/api/src/util/ensembleService.ts b/packages/api/src/util/ensembleService.ts
index b5dc9b7aed..b3da5a52a5 100644
--- a/packages/api/src/util/ensembleService.ts
+++ b/packages/api/src/util/ensembleService.ts
@@ -16,7 +16,7 @@
 /**
  * This file extends the Ensemble service (ensemble.js) using TypeScript. When the TS migration of ensemble.js is done, the contents of this file can be moved there.
  */
-import { AxiosError } from 'axios';
+import { AxiosError, AxiosResponse } from 'axios';
 import FarmAddonModel from '../models/farmAddonModel.js';
 import AddonPartnerModel from '../models/addonPartnerModel.js';
 import LocationModel from '../models/locationModel.js';
@@ -30,6 +30,65 @@ import type {
   ManagementPlan,
 } from './ensembleService.types.js';
 
+// TODO: After LF-4674 is merged, this can be removed and that function used instead
+export const mockGetFarmIrrigationPrescriptions = async (farm_id: string) => {
+  const partner = await AddonPartnerModel.getPartnerId(ENSEMBLE_BRAND);
+
+  if (!partner) {
+    throw customError('Ensemble partner not found', 400);
+  }
+
+  const farmEnsembleAddon = await FarmAddonModel.getOrganisationIds(farm_id, partner.id);
+
+  if (!farmEnsembleAddon) {
+    return [];
+  }
+
+  // In the real function, call Ensemble API here
+  // Pass organization_id and the correct time parameters
+
+  const ONE_DAY = 24 * 60 * 60 * 1000; // in ms
+
+  const irrigationPrescriptionsMinimalMock = [
+    {
+      id: new Date().getUTCDay(), // 0-6, 0 = Sunday
+      recommended_start_datetime: new Date().toISOString(),
+    },
+    {
+      id: new Date(Date.now() + ONE_DAY).getUTCDay(),
+      recommended_start_datetime: new Date(Date.now() + ONE_DAY).toISOString(),
+    },
+  ];
+
+  const mockData = await mockFetchIrrigationPrescriptionsFromEnsemble(farmEnsembleAddon.org_pk);
+
+  return mockData ?? irrigationPrescriptionsMinimalMock;
+};
+
+// Mock Ensemble API call allows mocking axios in tests
+export async function mockFetchIrrigationPrescriptionsFromEnsemble(org_pk: number) {
+  try {
+    const axiosObject = {
+      method: 'get',
+      url: `${ensembleAPI}/${org_pk}/irrigation_prescriptions/`,
+    };
+
+    const onError = (error: AxiosError) => {
+      const status = error.response?.status || 500;
+      const errorDetail = error.message ? `: ${error.message}` : '';
+      const message = `Error fetching IPs from ESci${errorDetail}`;
+      throw customError(message, status);
+    };
+
+    const { data } = (await ensembleAPICall(axiosObject, onError)) as AxiosResponse;
+
+    return data;
+  } catch (error) {
+    console.log(error);
+    return;
+  }
+}
+
 /**
 Gathers location and crop data to Ensemble API to initiate irrigation prescriptions
  *
diff --git a/packages/api/tests/task.test.js b/packages/api/tests/task.test.js
index cd597c087c..ef3ffe593b 100644
--- a/packages/api/tests/task.test.js
+++ b/packages/api/tests/task.test.js
@@ -1371,6 +1371,7 @@ describe('Task tests', () => {
             irrigation_task: {
               ...mocks.fakeIrrigationTask({ irrigation_type_name: 'PIVOT' }),
               irrigation_prescription_external_id,
+              location_id: field.location_id,
             },
             task_type_id,
             owner_user_id: user.user_id,
@@ -1401,7 +1402,7 @@ describe('Task tests', () => {
         );
       });
 
-      test('Should return an error if there is an attempt to associate the same irrigation_prescription_external_id with a second task', async () => {
+      test('Should return an error if there is an attempt to associate the same irrigation_prescription_external_id with a second task on the same farm', async () => {
         const { farm, field, user } = await setupFarmEnvironment(1);
 
         const [{ task_type_id }] = await mocks.task_typeFactory();
@@ -1414,6 +1415,7 @@ describe('Task tests', () => {
               irrigation_task: {
                 ...mocks.fakeIrrigationTask({ irrigation_type_name: 'PIVOT' }),
                 irrigation_prescription_external_id,
+                location_id: field.location_id,
               },
               task_type_id,
               owner_user_id: user.user_id,
diff --git a/packages/api/tests/timeNotification.test.js b/packages/api/tests/timeNotification.test.js
index b7f6697a8a..095805af7d 100644
--- a/packages/api/tests/timeNotification.test.js
+++ b/packages/api/tests/timeNotification.test.js
@@ -1,5 +1,7 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+
 /*
- *  Copyright 2019, 2020, 2021, 2022 LiteFarm.org
+ *  Copyright 2019, 2020, 2021, 2022, 2025 LiteFarm.org
  *  This file is part of LiteFarm.
  *
  *  LiteFarm is free software: you can redistribute it and/or modify
@@ -32,6 +34,11 @@ jest.mock('../src/middleware/acl/checkSchedulerJwt.js', () =>
   }),
 );
 
+import axios from 'axios';
+import { connectFarmToEnsemble } from './utils/ensembleUtils.js';
+jest.mock('axios');
+const mockedAxios = axios;
+
 describe('Time Based Notification Tests', () => {
   let farmOwner;
   let farm;
@@ -151,6 +158,16 @@ describe('Time Based Notification Tests', () => {
       .end(callback);
   }
 
+  async function postDailyNewIrrigationPrescriptions(data) {
+    const { farm_id } = data;
+    const response = await chai
+      .request(server)
+      .post(`/time_notification/new_irrigation_prescription/${farm_id}`)
+      .send({ isDayLaterThanUtc });
+
+    return response;
+  }
+
   // Clean up after test finishes
   afterAll(async (done) => {
     await tableCleanup(knex);
@@ -396,6 +413,30 @@ describe('Time Based Notification Tests', () => {
         });
       });
 
+      test('Daily notification_date correctly matches due_date when isDayLaterThanUtc=true', async (done) => {
+        isDayLaterThanUtc = true;
+        fakeToday = new Date();
+        fakeToday.setDate(fakeToday.getDate() + 1);
+
+        await createFullTask({
+          due_date: fakeToday.toISOString().split('T')[0],
+          assignee_user_id: farmWorker.user_id,
+        });
+
+        postDailyDueTodayTasks({ farm_id: farm.farm_id }, async (err, res) => {
+          expect(res.status).toBe(201);
+          const notifications = await knex('notification').where({
+            'notification.farm_id': farm.farm_id,
+            'notification.deleted': false,
+          });
+
+          const expectedDate = fakeToday.toISOString().split('T')[0];
+          expect(notifications[0].context.notification_date).toBe(expectedDate);
+
+          done();
+        });
+      });
+
       test('Farm owner should receive a due today notification', async (done) => {
         await createFullTask({
           due_date: fakeToday.toISOString().split('T')[0],
@@ -519,4 +560,79 @@ describe('Time Based Notification Tests', () => {
       });
     });
   });
+
+  describe('New Irrigation Prescription Notification Test', () => {
+    let farmWorker;
+    beforeEach(async () => {
+      [farmWorker] = await mocks.usersFactory();
+
+      await mocks.userFarmFactory(
+        {
+          promisedUser: [farmWorker],
+          promisedFarm: [farm],
+        },
+        mocks.fakeUserFarm({ role_id: 3 }),
+      );
+
+      await connectFarmToEnsemble(farm);
+    });
+
+    describe('Notification sent tests', () => {
+      beforeEach(async () => {
+        await mockedAxios.mockClear();
+      });
+
+      test('One notification record should be created for the last irrigation prescription', async () => {
+        await mockedAxios.mockResolvedValue({
+          status: 201,
+          data: [
+            { id: 123, recommended_start_datetime: '2025-05-07T00:00:00Z' },
+            { id: 124, recommended_start_datetime: '2025-05-08T00:00:00Z' },
+          ],
+        });
+
+        const res = await postDailyNewIrrigationPrescriptions({ farm_id: farm.farm_id });
+
+        expect(res.status).toBe(201);
+
+        const rows = await knex('notification')
+          .where('farm_id', farm.farm_id)
+          .whereRaw("(context->'irrigation_prescription_id') IN (?, ?)", [123, 124]);
+
+        expect(rows).toHaveLength(1);
+
+        expect(rows[0].context.irrigation_prescription_id).toBe(124);
+        expect(rows[0].ref.url).toBe('/irrigation_prescription/124');
+        expect(rows[0].title.translation_key).toBe(
+          'NOTIFICATION.NEW_IRRIGATION_PRESCRIPTION.TITLE',
+        );
+      });
+
+      test('Repeat notifications are not sent for the same irrigation prescription', async () => {
+        await mockedAxios.mockResolvedValue({
+          status: 201,
+          data: [
+            { id: 223, recommended_start_datetime: '2025-05-07T00:00:00Z' },
+            { id: 224, recommended_start_datetime: '2025-05-08T00:00:00Z' },
+          ],
+        });
+        const res1 = await postDailyNewIrrigationPrescriptions({ farm_id: farm.farm_id });
+
+        // 201 is controller response for notifications sent
+        expect(res1.status).toBe(201);
+
+        const res2 = await postDailyNewIrrigationPrescriptions({ farm_id: farm.farm_id });
+
+        // 200 is controller response for no notifications sent
+        expect(res2.status).toBe(200);
+
+        const rows = await knex('notification')
+          .where('farm_id', farm.farm_id)
+          .whereRaw("(context->'irrigation_prescription_id') IN (?, ?)", [223, 224]);
+
+        // There should be exactly 1 record in the DB
+        expect(rows).toHaveLength(1);
+      });
+    });
+  });
 });
diff --git a/packages/webapp/public/locales/en/translation.json b/packages/webapp/public/locales/en/translation.json
index 3fa9dedd6b..4ec2923a46 100644
--- a/packages/webapp/public/locales/en/translation.json
+++ b/packages/webapp/public/locales/en/translation.json
@@ -1499,6 +1499,10 @@
       "BODY": "You have tasks due today.",
       "TITLE": "Tasks due today"
     },
+    "NEW_IRRIGATION_PRESCRIPTION": {
+      "BODY": "A new irrigation prescription has been generated based on current weather, soil, and crop data.",
+      "TITLE": "New irrigation prescription ready"
+    },
     "NONE_TO_DISPLAY": "There are no notifications to display.",
     "PAGE_TITLE": "Notifications",
     "SENSOR_BULK_UPLOAD_FAIL": {
diff --git a/packages/webapp/src/assets/images/irrigation/notification-pivot-icon.svg b/packages/webapp/src/assets/images/irrigation/notification-pivot-icon.svg
new file mode 100644
index 0000000000..f62ef48050
--- /dev/null
+++ b/packages/webapp/src/assets/images/irrigation/notification-pivot-icon.svg
@@ -0,0 +1,19 @@
+<svg width="34" height="28" viewBox="0 0 34 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Group 1456">
+<g id="Group 1455">
+<path id="Vector" d="M18 22.6445V17.6445C18 17.0922 17.5523 16.6445 17 16.6445C16.4477 16.6445 16 17.0922 16 17.6445V22.6445C16 23.1968 16.4477 23.6445 17 23.6445C17.5523 23.6445 18 23.1968 18 22.6445Z" fill="white" stroke="#359D92" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path id="Vector_2" d="M21 21H13V27H21V21Z" fill="white" stroke="#359D92" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g id="Group 1454">
+<path id="Vector_3" d="M17 1L17 13" stroke="#359D92" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 4"/>
+<g id="Group 1452">
+<path id="Vector_4" d="M14 14C12.2747 11.0874 8.92548 7.51956 3 8.05353" stroke="#359D92" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 4"/>
+<path id="Vector_5" d="M12 16.2959C9.53501 15.5998 5.53326 15.7575 1 21" stroke="#359D92" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 4"/>
+</g>
+<g id="Group 1453">
+<path id="Vector_6" d="M20 14C21.7253 11.0874 25.0745 7.51956 31 8.05353" stroke="#359D92" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 4"/>
+<path id="Vector_7" d="M22 16.2959C24.465 15.5998 28.4667 15.7575 33 21" stroke="#359D92" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 4"/>
+</g>
+</g>
+</g>
+</svg>
diff --git a/packages/webapp/src/components/util/getNotificationTypeIcon.js b/packages/webapp/src/components/util/getNotificationTypeIcon.js
index 9a43266552..20b0074a8d 100644
--- a/packages/webapp/src/components/util/getNotificationTypeIcon.js
+++ b/packages/webapp/src/components/util/getNotificationTypeIcon.js
@@ -14,6 +14,7 @@
  */
 
 import { ReactComponent as SensorIcon } from '../../assets/images/farmMapFilter/Sensor.svg';
+import { ReactComponent as NotificationPivotIcon } from '../../assets/images/irrigation/notification-pivot-icon.svg';
 
 /**
  * Provides the appropriate icon for a specified notification type.
@@ -23,6 +24,7 @@ import { ReactComponent as SensorIcon } from '../../assets/images/farmMapFilter/
 export default function getNotificationTypeIcon(key) {
   const iconDict = {
     SENSOR: SensorIcon,
+    IRRIGATION_PRESCRIPTION: NotificationPivotIcon,
   };
 
   return iconDict[key];
diff --git a/packages/webapp/src/containers/IrrigationPrescription/index.tsx b/packages/webapp/src/containers/IrrigationPrescription/index.tsx
index 2d05b136a7..2f4c4b87c0 100644
--- a/packages/webapp/src/containers/IrrigationPrescription/index.tsx
+++ b/packages/webapp/src/containers/IrrigationPrescription/index.tsx
@@ -106,7 +106,7 @@ const IrrigationPrescription = ({
         }
       : {
           ...commonMockData,
-          id: 2,
+          id: isNaN(Number(ip_pk)) ? 2 : Number(ip_pk),
           prescription: {
             vriData: {
               zones: mockVriZones,


Reviews:
user: login: kathyavini
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:17:05Z
user: login: kathyavini
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T19:14:31Z
user: login: SayakaOno
body: Great job figuring this out -- I just looked at the code for the first time and learned a lot! 😵 From what I saw, it looks like the time a notification is sent depends on the time of year the farm was added, since `utc_offset` is set when the farm is created. Do you happen to know if that's right?

I hadn’t run the export server for a long time, and it took a bit of effort to get it working again 😢, but I successfully received notifications!
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-05-09T18:21:03Z
user: login: antsgar
body: Awesome work! 🙌 it's really interesting to see a PR for a portion of the codebase that we almost never work on, really refreshing ☺️ 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-10T00:58:20Z
user: login: kathyavini
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T16:42:22Z
user: login: kathyavini
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T16:42:57Z
user: login: antsgar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T14:45:35Z
user: login: SayakaOno
body: @kathyavini Thank you for looking into the documentation! The changes look great!

I was seeing these errors:
```
npm error code ENOTEMPTY
npm error syscall rename
npm error path /packages/api/node_modules/eslint-config-prettier
npm error dest /packages/api/node_modules/.eslint-config-prettier-VNN3FcXW
npm error errno -39
npm error ENOTEMPTY: directory not empty, rename '/packages/api/node_modules/eslint-config-prettier' -> '/packages/api/node_modules/.eslint-config-prettier-VNN3FcXW'
exited with code 217
```

And removing the volume fixed it!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-13T16:47:01Z
user: login: antsgar
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-13T21:58:22Z

Review comments:
diff_hunk: @@ -83,7 +85,7 @@ const timeNotificationController = {
         );
 
         if (hasTasksDueToday) {
-          await sendDailyDueTodayTaskNotification(farm_id, user_id);
+          await sendDailyDueTodayTaskNotification(farm_id, user_id, isDayLaterThanUtc);
path: packages/api/src/controllers/timeNotificationController.js
user: login: kathyavini
body: I'm pretty sure is the source of the bug here: https://lite-farm.atlassian.net/browse/LF-3673

Or more generally, the source of the bug that task filtering on "Take me there" from the notification list is not correct for UTC+ farms. Or as Denis said:

> sometimes you are having the notification that you have some tasks on due today, however, there may be just empty list of the tasks.

There is logic based on this argument but it was never passed. I have added a test case.
created_at: 2025-05-07T18:17:05Z
author_association: COLLABORATOR
diff_hunk: @@ -304,11 +304,14 @@ async function checkIrrigationTask(req) {
   const esciExternalId = req.body?.irrigation_task?.irrigation_prescription_external_id;
 
   if (esciExternalId) {
+    const { farm_id } = req.headers;
     const existing = await TaskModel.query()
+      .whereNotDeleted()
       .joinRelated('irrigation_task')
+      .join('location', 'irrigation_task.location_id', 'location.location_id')
       .select('task.*')
       .where('irrigation_task.irrigation_prescription_external_id', esciExternalId)
-      .whereNotDeleted()
+      .andWhere('location.farm_id', farm_id)
path: packages/api/src/middleware/validation/checkTask.js
user: login: kathyavini
body: This check wasn't originally limited by farm, and in production I don't think it would be possible for one IP to be generated for multiple farms anyway, as they have a `location_id` property, but strictly speaking this does match the check the frontend makes to hide the buttons, so maybe even beyond our testing phase it's fine to keep like this.
created_at: 2025-05-08T19:14:30Z
author_association: COLLABORATOR
diff_hunk: @@ -95,13 +97,65 @@ const timeNotificationController = {
       return res.status(400).send({ error });
     }
   },
+
+  /**
+   * Notifies all users of new irrigation prescriptions
+   * @param {Request} req request
+   * @param {Response} res response
+   * @async
+   */
+  async postDailyNewIrrigationPrescriptions(req, res) {
+    const { farm_id } = req.params;
+    const { isDayLaterThanUtc } = req.body;
+    try {
+      // TODO: Use real function after LF-4765 is merged
+      const farmIrrigationPrescriptions = await mockGetFarmIrrigationPrescriptions(farm_id);
+
+      let notificationsSent = 0;
+
+      for (const prescription of farmIrrigationPrescriptions) {
path: packages/api/src/controllers/timeNotificationController.js
user: login: antsgar
body: Should we just check the last IP here instead of looping through them? If this runs daily could we assume we'd have generated the notification for other prescriptions on previous runs -- and even if by some mistake we hadn't, the older IPs probably wouldn't be relevant anyway if there's a newer one?
created_at: 2025-05-10T00:51:31Z
author_association: COLLABORATOR
diff_hunk: @@ -83,7 +85,7 @@ const timeNotificationController = {
         );
 
         if (hasTasksDueToday) {
-          await sendDailyDueTodayTaskNotification(farm_id, user_id);
+          await sendDailyDueTodayTaskNotification(farm_id, user_id, isDayLaterThanUtc);
path: packages/api/src/controllers/timeNotificationController.js
user: login: antsgar
body: Good catch! It seems that bug has been happening since forever 😱 
created_at: 2025-05-10T00:52:42Z
author_association: COLLABORATOR
diff_hunk: @@ -152,4 +207,38 @@ async function sendDailyDueTodayTaskNotification(farmId, userId, isDayLaterThanU
   );
 }
 
+/**
+ * Sends notification to a user of a new irrigation prescription
+ * @param {String} farmId
+ * @param {String[]} userIds
+ * @param {Boolean} isDayLaterThanUtc  - offset “today” by +1 day for UTC+ zones
+ * @async
+ */
+async function sendDailyNewIrrigationPrescriptionNotification(
+  farmId,
+  userIds,
+  isDayLaterThanUtc,
+  irrigation_prescription_id,
+) {
+  const today = new Date();
+  if (isDayLaterThanUtc) today.setDate(today.getDate() + 1);
path: packages/api/src/controllers/timeNotificationController.js
user: login: antsgar
body: NIT but I prefer to avoid inline ifs as they're a bit hard to read for me personally
created_at: 2025-05-10T00:53:52Z
author_association: COLLABORATOR
diff_hunk: @@ -95,13 +97,65 @@ const timeNotificationController = {
       return res.status(400).send({ error });
     }
   },
+
+  /**
+   * Notifies all users of new irrigation prescriptions
+   * @param {Request} req request
+   * @param {Response} res response
+   * @async
+   */
+  async postDailyNewIrrigationPrescriptions(req, res) {
+    const { farm_id } = req.params;
+    const { isDayLaterThanUtc } = req.body;
+    try {
+      // TODO: Use real function after LF-4765 is merged
+      const farmIrrigationPrescriptions = await mockGetFarmIrrigationPrescriptions(farm_id);
+
+      let notificationsSent = 0;
+
+      for (const prescription of farmIrrigationPrescriptions) {
path: packages/api/src/controllers/timeNotificationController.js
user: login: kathyavini
body: Okay, so the first time this runs for that farm, we want to generate just one notification? I guess I was expecting we would want two, so that every unseen IP in the table list has one notification corresponding to it.

But in this case (the first run) there would be just one notification, for tomorrow's IP? (Actually this is assuming that ESci will be returning us two IPs right from the start if we filter on today and tomorrow, and I don't know if that is the case 🤔 I think it's possible the first call might have just one entry in the table anyway!)
created_at: 2025-05-12T16:42:21Z
author_association: COLLABORATOR
diff_hunk: @@ -152,4 +207,38 @@ async function sendDailyDueTodayTaskNotification(farmId, userId, isDayLaterThanU
   );
 }
 
+/**
+ * Sends notification to a user of a new irrigation prescription
+ * @param {String} farmId
+ * @param {String[]} userIds
+ * @param {Boolean} isDayLaterThanUtc  - offset “today” by +1 day for UTC+ zones
+ * @async
+ */
+async function sendDailyNewIrrigationPrescriptionNotification(
+  farmId,
+  userIds,
+  isDayLaterThanUtc,
+  irrigation_prescription_id,
+) {
+  const today = new Date();
+  if (isDayLaterThanUtc) today.setDate(today.getDate() + 1);
path: packages/api/src/controllers/timeNotificationController.js
user: login: kathyavini
body: I have the same preference! This was following the other two controller methods, but I'll go ahead and change it in all three.
created_at: 2025-05-12T16:42:57Z
author_association: COLLABORATOR
diff_hunk: @@ -95,13 +97,65 @@ const timeNotificationController = {
       return res.status(400).send({ error });
     }
   },
+
+  /**
+   * Notifies all users of new irrigation prescriptions
+   * @param {Request} req request
+   * @param {Response} res response
+   * @async
+   */
+  async postDailyNewIrrigationPrescriptions(req, res) {
+    const { farm_id } = req.params;
+    const { isDayLaterThanUtc } = req.body;
+    try {
+      // TODO: Use real function after LF-4765 is merged
+      const farmIrrigationPrescriptions = await mockGetFarmIrrigationPrescriptions(farm_id);
+
+      let notificationsSent = 0;
+
+      for (const prescription of farmIrrigationPrescriptions) {
path: packages/api/src/controllers/timeNotificationController.js
user: login: antsgar
body: I think they'll generate one IP each time our cronjob triggers, so if we trigger the cronjob for the first time today, they'll generate one IP for tomorrow and that's what we're getting on the first call here
created_at: 2025-05-13T14:45:35Z
author_association: COLLABORATOR

PR comments:
user: login: kathyavini
created_at: 2025-05-09T19:02:26Z
body: >  From what I saw, it looks like the time a notification is sent depends on the time of year the farm was added, since utc_offset is set when the farm is created. Do you happen to know if that's right?

Ahhh timezones! 😭 I hadn't worried about it, but I do think we need to make an adjust.

The `utc_offset` value in the database is only storing the [`rawOffset`](https://developers.google.com/maps/documentation/timezone/requests-timezone#TimeZoneResponse) from the Google Timezone API, so it is not taking into account daylight savings; when the farm was created is therefore irrelevant, which I think is probably good. At least it is consistent. I think that what this means, though, is that the notification scheduler is off for those farms if/when the farms are on daylight savings. I wish it meant that the notification comes an hour early (5am instead of 6am in the summer) but I guess it would instead be an hour late (7am).

Since we never stored the `dstOffset` from the timezone API and we're not querying the timezone API again (and I'm pretty sure we don't want to do this for every single farm every single day anyway), I might prefer the very low-effort solution of just pushing our notifications earlier by an hour? In the winter, or in non-daylight savings zones, they would come at 5am and 4am respectively, but an hour earlier for a start of day notification doesn't sound bad to me. What do you think @SayakaOno and @antsgar?

Oh! And completely unrelatedly, what didn't work @SayakaOno with the export server? 😢 Was it MacOS Docker app errors?
author_association: COLLABORATOR
user: login: kathyavini
created_at: 2025-05-12T17:34:38Z
body: @antsgar did you also have a thought to the daylight savings issue that Sayaka brought up, and the proposed solution [above](https://github.com/LiteFarmOrg/LiteFarm/pull/3762#issuecomment-2867634885)?
author_association: COLLABORATOR
user: login: Duncan-Brain
created_at: 2025-05-12T18:40:30Z
body: Sorry for piping in.. but I saw farm dates +timezones being talked about! Which I also recently looked into thinking I had to do some parts ip notification fetching. 

~~My understanding is that @SayakaOno is correct. Depending on the time of the year a farm is created, the same farm could have two different `utc_offset` values.~~ (see below clarification)

Also the Api call is non-blocking. Which means if the google api, or we hit our api limit at creation it could be `null`. (I was seeing errors locally a few years ago when my google API key was not set to work with the Timezones Api in google cloud console)

I think your solution @kathyavini is the best and fastest we have for now to make it work with what we have given the unreliable `utc_offset`. 

As an alternate, and to take a step towards proper farm dates, it might not be too too hard to populate the db farm table with the timezone name "America/Toronto" and use that. The proposed `dayjs` replacement for `moment` uses that: https://day.js.org/docs/en/timezone/timezone.
author_association: COLLABORATOR
user: login: kathyavini
created_at: 2025-05-12T19:09:08Z
body: > My understanding is that @SayakaOno is correct. Depending on the time of the year a farm is created, the same farm could have two different utc_offset values.

@Duncan-Brain on what basis are you proposing this? The function that populates `utc_offset` is `getUTCOffsetFromGridPoints()`. The return object from the timezone API looks like this:

```js
{
  dstOffset: 3600,
  rawOffset: -28800,
  status: "OK",
  timeZoneId: "America/Vancouver",
  timeZoneName: "Pacific Daylight Time",
}
```

And we're only storing the `rawOffset`. This IS the daylight-savings neutral timezone that corresponds to the timezone name you proposed above. `dstOffset` is the daylight savings adjustment. I linked the Google Timezone API docs about it in my comment above. Did you find something to suggest otherwise?
author_association: COLLABORATOR
user: login: Duncan-Brain
created_at: 2025-05-12T19:34:44Z
body: @kathyavini I have seen the docs yes, but it wasn't clear to me reading what rawOffset return reading it. I can read it your way now! It says:

> The offset from UTC (in seconds) for the given location. This does not take into effect daylight savings.

I was reading it as : `UTC.now()  - LocalTime.now()` ... dst was not determined. But I see what you are saying now that it reads. `UTC.now()  - LocalTimeUTC.now()`.. not adjusted for daylight savings. Sorry it was subtle to me! 

In any case it can be `null` in some cases! If it is not happening in prod then that is great!

Still moral of the story is I like your solution for now especially since Chile and Nova Scotia are in the same timezone but probably have opposite DST schedules. So no harm in doing it earlier to cover both cases.
author_association: COLLABORATOR
user: login: antsgar
created_at: 2025-05-13T14:47:09Z
body: @kathyavini sorry I missed answering to the timezones question! Lowest effort solution is okay here, I don't think the exact timing is as important as making sure that when they first login for the day they see the notification
author_association: COLLABORATOR
user: login: kathyavini
created_at: 2025-05-13T16:01:00Z
body: The timing for all notifications has been pushed back by one hour to land at our desired time if/when the farm is on summer time, per discussion 🙏  The cronjob will not need adjusting as it was already calculated for daylight time in Alberta (MDT).

And the controller + tests have been updated so only the last (of the two expected) IPs generates a notification.

Thank you for re-reviewing!
author_association: COLLABORATOR