Repository: terraware/terraware-server
Pull request #3043: SW-6632 Added scheduled event to update elevation of plots
Author: login: tommylau523
Created: 2025-04-18T21:53:11Z | Merged: 2025-04-19T01:46:14Z



Diff:
diff --git a/src/main/kotlin/com/terraformation/backend/customer/db/ParentStore.kt b/src/main/kotlin/com/terraformation/backend/customer/db/ParentStore.kt
index abbc0370db34..cf1d09bc8ce4 100644
--- a/src/main/kotlin/com/terraformation/backend/customer/db/ParentStore.kt
+++ b/src/main/kotlin/com/terraformation/backend/customer/db/ParentStore.kt
@@ -138,10 +138,7 @@ class ParentStore(private val dslContext: DSLContext) {
       fetchFieldById(facilityId, FACILITIES.ID, FACILITIES.ORGANIZATION_ID)
 
   fun getOrganizationId(monitoringPlotId: MonitoringPlotId): OrganizationId? =
-      fetchFieldById(
-          monitoringPlotId,
-          MONITORING_PLOTS.ID,
-          MONITORING_PLOTS.plantingSubzones.plantingSites.ORGANIZATION_ID)
+      fetchFieldById(monitoringPlotId, MONITORING_PLOTS.ID, MONITORING_PLOTS.ORGANIZATION_ID)
 
   fun getOrganizationId(observationId: ObservationId): OrganizationId? =
       fetchFieldById(observationId, OBSERVATIONS.ID, OBSERVATIONS.plantingSites.ORGANIZATION_ID)
diff --git a/src/main/kotlin/com/terraformation/backend/customer/model/IndividualUser.kt b/src/main/kotlin/com/terraformation/backend/customer/model/IndividualUser.kt
index 5855f89327e8..d0deb07acce8 100644
--- a/src/main/kotlin/com/terraformation/backend/customer/model/IndividualUser.kt
+++ b/src/main/kotlin/com/terraformation/backend/customer/model/IndividualUser.kt
@@ -625,6 +625,9 @@ data class IndividualUser(
 
   override fun canUpdateInternalVariableWorkflowDetails(projectId: ProjectId) = isTFExpertOrHigher()
 
+  override fun canUpdateMonitoringPlot(monitoringPlotId: MonitoringPlotId): Boolean =
+      isAdminOrHigher(parentStore.getOrganizationId(monitoringPlotId))
+
   override fun canUpdateNotification(notificationId: NotificationId) =
       canReadNotification(notificationId)
 
diff --git a/src/main/kotlin/com/terraformation/backend/customer/model/PermissionRequirements.kt b/src/main/kotlin/com/terraformation/backend/customer/model/PermissionRequirements.kt
index 1bbd15c3a2f6..86590c7df9dc 100644
--- a/src/main/kotlin/com/terraformation/backend/customer/model/PermissionRequirements.kt
+++ b/src/main/kotlin/com/terraformation/backend/customer/model/PermissionRequirements.kt
@@ -1577,6 +1577,15 @@ class PermissionRequirements(private val user: TerrawareUser) {
     }
   }
 
+  fun updateMonitoringPlot(monitoringPlotId: MonitoringPlotId) {
+    user.recordPermissionChecks {
+      if (!user.canUpdateMonitoringPlot(monitoringPlotId)) {
+        readMonitoringPlot(monitoringPlotId)
+        throw AccessDeniedException("No permission to update monitoring plot $monitoringPlotId")
+      }
+    }
+  }
+
   fun updateNotification(notificationId: NotificationId) {
     user.recordPermissionChecks {
       if (!user.canUpdateNotification(notificationId)) {
diff --git a/src/main/kotlin/com/terraformation/backend/customer/model/TerrawareUser.kt b/src/main/kotlin/com/terraformation/backend/customer/model/TerrawareUser.kt
index 71f836fa67b4..ae9b0b70f3a4 100644
--- a/src/main/kotlin/com/terraformation/backend/customer/model/TerrawareUser.kt
+++ b/src/main/kotlin/com/terraformation/backend/customer/model/TerrawareUser.kt
@@ -577,7 +577,7 @@ interface TerrawareUser : Principal, UserDetails {
 
   fun canUpdateInternalVariableWorkflowDetails(projectId: ProjectId): Boolean = defaultPermission
 
-  fun canUpdateSpecificGlobalRoles(globalRoles: Set<GlobalRole>): Boolean = defaultPermission
+  fun canUpdateMonitoringPlot(monitoringPlotId: MonitoringPlotId): Boolean = defaultPermission
 
   fun canUpdateNotification(notificationId: NotificationId): Boolean = defaultPermission
 
@@ -617,6 +617,8 @@ interface TerrawareUser : Principal, UserDetails {
 
   fun canUpdateSpecies(speciesId: SpeciesId): Boolean = defaultPermission
 
+  fun canUpdateSpecificGlobalRoles(globalRoles: Set<GlobalRole>): Boolean = defaultPermission
+
   fun canUpdateSubLocation(subLocationId: SubLocationId): Boolean = defaultPermission
 
   fun canUpdateSubmissionStatus(deliverableId: DeliverableId, projectId: ProjectId): Boolean =
diff --git a/src/main/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationScheduler.kt b/src/main/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationScheduler.kt
new file mode 100644
index 000000000000..63b922967a61
--- /dev/null
+++ b/src/main/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationScheduler.kt
@@ -0,0 +1,77 @@
+package com.terraformation.backend.daily
+
+import com.terraformation.backend.config.TerrawareServerConfig
+import com.terraformation.backend.customer.model.SystemUser
+import com.terraformation.backend.log.perClassLogger
+import com.terraformation.backend.time.ClockAdvancedEvent
+import com.terraformation.backend.tracking.db.PlantingSiteStore
+import com.terraformation.backend.tracking.mapbox.MapboxService
+import jakarta.inject.Inject
+import jakarta.inject.Named
+import org.jobrunr.scheduling.JobScheduler
+import org.jobrunr.scheduling.cron.Cron
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.context.event.EventListener
+
+@ConditionalOnProperty(TerrawareServerConfig.DAILY_TASKS_ENABLED_PROPERTY, matchIfMissing = true)
+@Named
+class MonitoringPlotElevationScheduler(
+    private val config: TerrawareServerConfig,
+    private val mapboxService: MapboxService,
+    private val plantingSiteStore: PlantingSiteStore,
+    private val systemUser: SystemUser,
+) {
+  private val MONITORING_PLOT_BATCH_SIZE = 50
+  private val log = perClassLogger()
+
+  @Inject
+  fun schedule(scheduler: JobScheduler) {
+    if (config.dailyTasks.enabled) {
+      scheduler.scheduleRecurrently<MonitoringPlotElevationScheduler>(
+          javaClass.simpleName, Cron.every15minutes()) {
+            updatePlotElevation(MONITORING_PLOT_BATCH_SIZE)
+          }
+    }
+  }
+
+  @Suppress("MemberVisibilityCanBePrivate") // Called by JobRunr
+  fun updatePlotElevation(limit: Int) {
+    systemUser.run {
+      val plots =
+          try {
+            plantingSiteStore.fetchMonitoringPlotsWithoutElevation(limit)
+          } catch (e: Exception) {
+            log.warn("Failed to fetch monitoring plots without elevation data: ${e.message}")
+            return@run
+          }
+
+      val elevationByPlotId =
+          plots
+              .mapNotNull { plot ->
+                val elevation =
+                    try {
+                      mapboxService.getElevation(plot.boundary.centroid).toBigDecimal()
+                    } catch (e: Exception) {
+                      log.warn(
+                          "Failed to fetch elevation for monitoring plot ${plot.id}: ${e.message}")
+                      return@mapNotNull null
+                    }
+
+                plot.id to elevation
+              }
+              .toMap()
+
+      try {
+        val rowsUpdated = plantingSiteStore.updateMonitoringPlotElevation(elevationByPlotId)
+        log.info("Updated elevation for $rowsUpdated plots")
+      } catch (e: Exception) {
+        log.warn("Failed to write elevation to monitoring plots table: ${e.message}")
+      }
+    }
+  }
+
+  @EventListener
+  fun on(@Suppress("UNUSED_PARAMETER") event: ClockAdvancedEvent) {
+    updatePlotElevation(MONITORING_PLOT_BATCH_SIZE)
+  }
+}
diff --git a/src/main/kotlin/com/terraformation/backend/tracking/db/PlantingSiteStore.kt b/src/main/kotlin/com/terraformation/backend/tracking/db/PlantingSiteStore.kt
index e88600da497a..bc5e834b45e3 100644
--- a/src/main/kotlin/com/terraformation/backend/tracking/db/PlantingSiteStore.kt
+++ b/src/main/kotlin/com/terraformation/backend/tracking/db/PlantingSiteStore.kt
@@ -13,9 +13,11 @@ import com.terraformation.backend.db.attach
 import com.terraformation.backend.db.default_schema.NotificationType
 import com.terraformation.backend.db.default_schema.OrganizationId
 import com.terraformation.backend.db.default_schema.ProjectId
+import com.terraformation.backend.db.default_schema.UserType
 import com.terraformation.backend.db.forMultiset
 import com.terraformation.backend.db.tracking.MonitoringPlotHistoryId
 import com.terraformation.backend.db.tracking.MonitoringPlotId
+import com.terraformation.backend.db.tracking.MonitoringPlotIdConverter
 import com.terraformation.backend.db.tracking.PlantingSeasonId
 import com.terraformation.backend.db.tracking.PlantingSiteHistoryId
 import com.terraformation.backend.db.tracking.PlantingSiteId
@@ -106,6 +108,7 @@ import org.jooq.DSLContext
 import org.jooq.Field
 import org.jooq.Record
 import org.jooq.impl.DSL
+import org.jooq.impl.SQLDataType
 import org.locationtech.jts.geom.MultiPolygon
 import org.locationtech.jts.geom.Point
 import org.locationtech.jts.geom.Polygon
@@ -1508,6 +1511,67 @@ class PlantingSiteStore(
     return monitoringPlotsRow.id!!
   }
 
+  fun fetchMonitoringPlotsWithoutElevation(limit: Int = 50): List<MonitoringPlotModel> {
+    return dslContext
+        .select(
+            MONITORING_PLOTS.BOUNDARY,
+            MONITORING_PLOTS.ELEVATION_METERS,
+            MONITORING_PLOTS.ID,
+            MONITORING_PLOTS.IS_AD_HOC,
+            MONITORING_PLOTS.IS_AVAILABLE,
+            MONITORING_PLOTS.PERMANENT_CLUSTER,
+            MONITORING_PLOTS.PLOT_NUMBER,
+            MONITORING_PLOTS.SIZE_METERS,
+        )
+        .from(MONITORING_PLOTS)
+        .where(MONITORING_PLOTS.ELEVATION_METERS.isNull)
+        .apply {
+          // For non-system users, check organization memberships
+          if (currentUser().userType != UserType.System) {
+            this.and(MONITORING_PLOTS.ORGANIZATION_ID.`in`(currentUser().organizationRoles.keys))
+          }
+        }
+        .orderBy(MONITORING_PLOTS.ID.desc())
+        .limit(limit)
+        .fetch { record ->
+          MonitoringPlotModel(
+              boundary = record[MONITORING_PLOTS.BOUNDARY]!! as Polygon,
+              elevationMeters = record[MONITORING_PLOTS.ELEVATION_METERS],
+              id = record[MONITORING_PLOTS.ID]!!,
+              isAdHoc = record[MONITORING_PLOTS.IS_AD_HOC]!!,
+              isAvailable = record[MONITORING_PLOTS.IS_AVAILABLE]!!,
+              permanentCluster = record[MONITORING_PLOTS.PERMANENT_CLUSTER],
+              plotNumber = record[MONITORING_PLOTS.PLOT_NUMBER]!!,
+              sizeMeters = record[MONITORING_PLOTS.SIZE_METERS]!!,
+          )
+        }
+        .filter { currentUser().canReadMonitoringPlot(it.id) }
+  }
+
+  fun updateMonitoringPlotElevation(elevationByPlotId: Map<MonitoringPlotId, BigDecimal>): Int {
+
+    val elevationTable =
+        DSL.values(
+            *elevationByPlotId
+                .filter { currentUser().canUpdateMonitoringPlot(it.key) }
+                .ifEmpty {
+                  return 0
+                }
+                .map { (plotId, elevation) -> DSL.row(plotId, elevation) }
+                .toTypedArray())
+
+    val plotIdField =
+        elevationTable.field(0, SQLDataType.BIGINT.asConvertedDataType(MonitoringPlotIdConverter()))
+    val elevationField = elevationTable.field(1, BigDecimal::class.java)
+
+    return dslContext
+        .update(MONITORING_PLOTS)
+        .set(MONITORING_PLOTS.ELEVATION_METERS, elevationField)
+        .from(elevationTable)
+        .where(MONITORING_PLOTS.ID.eq(plotIdField))
+        .execute()
+  }
+
   /**
    * Creates permanent clusters with a specific set of cluster numbers. The permanent clusters may
    * include a mix of newly-created monitoring plots and plots that exist already but were only used
diff --git a/src/test/kotlin/com/terraformation/backend/customer/model/PermissionRequirementsTest.kt b/src/test/kotlin/com/terraformation/backend/customer/model/PermissionRequirementsTest.kt
index 672e708b24a4..bdb6559c5efe 100644
--- a/src/test/kotlin/com/terraformation/backend/customer/model/PermissionRequirementsTest.kt
+++ b/src/test/kotlin/com/terraformation/backend/customer/model/PermissionRequirementsTest.kt
@@ -1028,6 +1028,13 @@ internal class PermissionRequirementsTest : RunsAsUser {
             canUpdateInternalVariableWorkflowDetails(projectId)
           }
 
+  @Test
+  fun updateMonitoringPlot() =
+      allow { updateMonitoringPlot(monitoringPlotId) } ifUser
+          {
+            canUpdateMonitoringPlot(monitoringPlotId)
+          }
+
   @Test
   fun updateNotification() =
       allow { updateNotification(notificationId) } ifUser { canUpdateNotification(notificationId) }
diff --git a/src/test/kotlin/com/terraformation/backend/customer/model/PermissionTest.kt b/src/test/kotlin/com/terraformation/backend/customer/model/PermissionTest.kt
index 684d7a1bd418..14550eaca899 100644
--- a/src/test/kotlin/com/terraformation/backend/customer/model/PermissionTest.kt
+++ b/src/test/kotlin/com/terraformation/backend/customer/model/PermissionTest.kt
@@ -344,6 +344,7 @@ internal class PermissionTest : DatabaseTest() {
           monitoringPlotId,
           insertMonitoringPlot(
               createdBy = userId,
+              organizationId = getDatabaseId(OrganizationId(monitoringPlotId.value / 1000)),
               plantingSubzoneId = getDatabaseId(PlantingSubzoneId(monitoringPlotId.value))))
     }
 
@@ -570,6 +571,7 @@ internal class PermissionTest : DatabaseTest() {
     permissions.expect(
         *monitoringPlotIds.forOrg1(),
         readMonitoringPlot = true,
+        updateMonitoringPlot = true,
     )
 
     permissions.expect(
@@ -847,6 +849,7 @@ internal class PermissionTest : DatabaseTest() {
     permissions.expect(
         *monitoringPlotIds.forOrg1(),
         readMonitoringPlot = true,
+        updateMonitoringPlot = true,
     )
 
     permissions.expect(
@@ -1486,6 +1489,7 @@ internal class PermissionTest : DatabaseTest() {
     permissions.expect(
         *monitoringPlotIds.toTypedArray(),
         readMonitoringPlot = true,
+        updateMonitoringPlot = true,
     )
 
     permissions.expect(
@@ -3347,6 +3351,7 @@ internal class PermissionTest : DatabaseTest() {
     fun expect(
         vararg monitoringPlotIds: MonitoringPlotId,
         readMonitoringPlot: Boolean = false,
+        updateMonitoringPlot: Boolean = false,
     ) {
       monitoringPlotIds.forEach { monitoringPlotId ->
         val idInDatabase = getDatabaseId(monitoringPlotId)
@@ -3355,6 +3360,10 @@ internal class PermissionTest : DatabaseTest() {
             readMonitoringPlot,
             user.canReadMonitoringPlot(idInDatabase),
             "Can read monitoring plot $monitoringPlotId")
+        assertEquals(
+            updateMonitoringPlot,
+            user.canUpdateMonitoringPlot(idInDatabase),
+            "Can update monitoring plot $monitoringPlotId")
 
         uncheckedMonitoringPlots.remove(monitoringPlotId)
       }
diff --git a/src/test/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationSchedulerTest.kt b/src/test/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationSchedulerTest.kt
new file mode 100644
index 000000000000..79a0d4b24947
--- /dev/null
+++ b/src/test/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationSchedulerTest.kt
@@ -0,0 +1,95 @@
+package com.terraformation.backend.daily
+
+import com.terraformation.backend.config.TerrawareServerConfig
+import com.terraformation.backend.customer.model.SystemUser
+import com.terraformation.backend.db.tracking.MonitoringPlotId
+import com.terraformation.backend.polygon
+import com.terraformation.backend.tracking.db.PlantingSiteStore
+import com.terraformation.backend.tracking.mapbox.MapboxService
+import com.terraformation.backend.tracking.model.MonitoringPlotModel
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import java.math.BigDecimal
+import java.net.URI
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class MonitoringPlotElevationSchedulerTest {
+  val mapboxService: MapboxService = mockk()
+  val plantingSiteStore: PlantingSiteStore = mockk()
+
+  val scheduler: MonitoringPlotElevationScheduler by lazy {
+    MonitoringPlotElevationScheduler(
+        TerrawareServerConfig(
+            webAppUrl = URI("https://terraware.io"),
+            keycloak =
+                TerrawareServerConfig.KeycloakConfig(
+                    apiClientId = "test",
+                    apiClientGroupName = "test",
+                    apiClientUsernamePrefix = "test")),
+        mapboxService,
+        plantingSiteStore,
+        SystemUser(mockk()))
+  }
+
+  @BeforeEach
+  fun setup() {
+    val boundary1 = polygon(11.0, 11.0, 13.0, 13.0)
+    val boundary2 = polygon(21.0, 21.0, 23.0, 23.0)
+    val boundary3 = polygon(31.0, 31.0, 33.0, 33.0)
+    every { plantingSiteStore.fetchMonitoringPlotsWithoutElevation(any()) } returns
+        listOf(
+            MonitoringPlotModel(
+                boundary = boundary1,
+                elevationMeters = null,
+                id = MonitoringPlotId(1),
+                isAdHoc = false,
+                isAvailable = true,
+                permanentCluster = null,
+                plotNumber = 1L,
+                sizeMeters = 30,
+            ),
+            MonitoringPlotModel(
+                boundary = boundary2,
+                elevationMeters = null,
+                id = MonitoringPlotId(2),
+                isAdHoc = false,
+                isAvailable = true,
+                permanentCluster = null,
+                plotNumber = 2L,
+                sizeMeters = 30,
+            ),
+            MonitoringPlotModel(
+                boundary = boundary3,
+                elevationMeters = null,
+                id = MonitoringPlotId(3),
+                isAdHoc = false,
+                isAvailable = true,
+                permanentCluster = null,
+                plotNumber = 3L,
+                sizeMeters = 30,
+            ),
+        )
+
+    every { plantingSiteStore.updateMonitoringPlotElevation(any()) } returns 0
+
+    every { mapboxService.getElevation(boundary1.centroid) } returns 1.5
+    every { mapboxService.getElevation(boundary2.centroid) } returns 2.5
+    every { mapboxService.getElevation(boundary3.centroid) } returns 3.5
+  }
+
+  @Test
+  fun `fetches elevation data and updates for each plot`() {
+    scheduler.updatePlotElevation(10)
+
+    verify {
+      plantingSiteStore.updateMonitoringPlotElevation(
+          mapOf(
+              MonitoringPlotId(1) to BigDecimal(1.5),
+              MonitoringPlotId(2) to BigDecimal(2.5),
+              MonitoringPlotId(3) to BigDecimal(3.5),
+          ))
+    }
+  }
+}
diff --git a/src/test/kotlin/com/terraformation/backend/tracking/db/plantingSiteStore/PlantingSiteStoreCreateAdHocMonitoringPlotsTest.kt b/src/test/kotlin/com/terraformation/backend/tracking/db/plantingSiteStore/PlantingSiteStoreCreateAdHocMonitoringPlotsTest.kt
index 617b679f47a0..29d51fcbd6ef 100644
--- a/src/test/kotlin/com/terraformation/backend/tracking/db/plantingSiteStore/PlantingSiteStoreCreateAdHocMonitoringPlotsTest.kt
+++ b/src/test/kotlin/com/terraformation/backend/tracking/db/plantingSiteStore/PlantingSiteStoreCreateAdHocMonitoringPlotsTest.kt
@@ -23,7 +23,7 @@ import org.junit.jupiter.api.Nested
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.assertThrows
 
-class PlantingSiteStoreTest : DatabaseTest(), RunsAsDatabaseUser {
+class PlantingSiteStoreCreateAdHocMonitoringPlotsTest : DatabaseTest(), RunsAsDatabaseUser {
   override lateinit var user: TerrawareUser
 
   protected val clock = TestClock()
diff --git a/src/test/kotlin/com/terraformation/backend/tracking/db/plantingSiteStore/PlantingSiteStoreMonitoringPlotElevationTest.kt b/src/test/kotlin/com/terraformation/backend/tracking/db/plantingSiteStore/PlantingSiteStoreMonitoringPlotElevationTest.kt
new file mode 100644
index 000000000000..86b74550a4af
--- /dev/null
+++ b/src/test/kotlin/com/terraformation/backend/tracking/db/plantingSiteStore/PlantingSiteStoreMonitoringPlotElevationTest.kt
@@ -0,0 +1,341 @@
+package com.terraformation.backend.tracking.db.plantingSiteStore
+
+import com.terraformation.backend.RunsAsDatabaseUser
+import com.terraformation.backend.TestClock
+import com.terraformation.backend.TestEventPublisher
+import com.terraformation.backend.TestSingletons
+import com.terraformation.backend.customer.db.ParentStore
+import com.terraformation.backend.customer.model.SystemUser
+import com.terraformation.backend.customer.model.TerrawareUser
+import com.terraformation.backend.db.DatabaseTest
+import com.terraformation.backend.db.IdentifierGenerator
+import com.terraformation.backend.db.default_schema.OrganizationId
+import com.terraformation.backend.db.default_schema.Role
+import com.terraformation.backend.db.tracking.tables.records.MonitoringPlotsRecord
+import com.terraformation.backend.multiPolygon
+import com.terraformation.backend.polygon
+import com.terraformation.backend.tracking.db.PlantingSiteStore
+import com.terraformation.backend.tracking.model.MonitoringPlotModel
+import java.math.BigDecimal
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+
+class PlantingSiteStoreMonitoringPlotElevationTest : DatabaseTest(), RunsAsDatabaseUser {
+  override lateinit var user: TerrawareUser
+
+  protected val clock = TestClock()
+  protected val eventPublisher = TestEventPublisher()
+  protected val store: PlantingSiteStore by lazy {
+    PlantingSiteStore(
+        clock,
+        TestSingletons.countryDetector,
+        dslContext,
+        eventPublisher,
+        IdentifierGenerator(clock, dslContext),
+        monitoringPlotsDao,
+        ParentStore(dslContext),
+        plantingSeasonsDao,
+        plantingSitesDao,
+        plantingSubzonesDao,
+        plantingZonesDao)
+  }
+
+  private lateinit var organizationId: OrganizationId
+
+  @BeforeEach
+  fun setUp() {
+    organizationId = insertOrganization()
+    insertOrganizationUser(user.userId, organizationId, Role.Admin)
+  }
+
+  @Nested
+  inner class FetchMonitoringPlotsWithoutElevation {
+    @Test
+    fun `returns only monitoring plots without elevation and within user organizations`() {
+      val secondOrganizationId = insertOrganization()
+      insertOrganizationUser(user.userId, secondOrganizationId, Role.Contributor)
+
+      val otherUserId = insertUser()
+      val otherOrganizationId = insertOrganization(createdBy = otherUserId)
+      insertOrganizationUser(otherUserId, otherOrganizationId, Role.Admin)
+
+      insertPlantingSite(boundary = multiPolygon(2.0), organizationId = organizationId)
+      val org1Site1PlotId1 =
+          insertMonitoringPlot(
+              boundary = polygon(1.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 1L)
+      val org1Site1PlotId2 =
+          insertMonitoringPlot(
+              boundary = polygon(1.5),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 2L)
+      // Not visible because it has elevation
+      insertMonitoringPlot(
+          boundary = polygon(10.0),
+          elevationMeters = BigDecimal.TEN,
+          organizationId = organizationId,
+          plotNumber = 3L)
+
+      insertPlantingSite(boundary = multiPolygon(4.0), organizationId = organizationId)
+      val org1Site2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(2.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 4L)
+
+      insertPlantingSite(boundary = multiPolygon(3.0), organizationId = secondOrganizationId)
+      val org2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(3.0),
+              elevationMeters = null,
+              organizationId = secondOrganizationId,
+              plotNumber = 1L)
+
+      insertPlantingSite(boundary = multiPolygon(10.0), organizationId = otherOrganizationId)
+      insertPlantingSiteHistory()
+      val otherOrgPlotId =
+          insertMonitoringPlot(
+              boundary = polygon(4.0),
+              elevationMeters = null,
+              organizationId = otherOrganizationId,
+              plotNumber = 1L)
+      // Not visible because it has elevation
+      insertMonitoringPlot(
+          boundary = polygon(10.0),
+          elevationMeters = BigDecimal.TWO,
+          organizationId = otherOrganizationId,
+          plotNumber = 2L)
+
+      val org1Site1PlotModel1 =
+          MonitoringPlotModel(
+              boundary = polygon(1.0),
+              elevationMeters = null,
+              id = org1Site1PlotId1,
+              isAdHoc = false,
+              isAvailable = true,
+              permanentCluster = null,
+              plotNumber = 1L,
+              sizeMeters = 30,
+          )
+      val org1Site1PlotModel2 =
+          org1Site1PlotModel1.copy(boundary = polygon(1.5), id = org1Site1PlotId2, plotNumber = 2L)
+
+      val org1Site2PlotModel =
+          org1Site1PlotModel1.copy(boundary = polygon(2.0), id = org1Site2PlotId, plotNumber = 4L)
+
+      val org2PlotIdModel =
+          org1Site1PlotModel1.copy(boundary = polygon(3.0), id = org2PlotId, plotNumber = 1L)
+
+      val otherOrgPlotModel =
+          org1Site1PlotModel1.copy(boundary = polygon(4.0), id = otherOrgPlotId, plotNumber = 1L)
+
+      assertEquals(
+          listOf(org2PlotIdModel, org1Site2PlotModel, org1Site1PlotModel2, org1Site1PlotModel1),
+          store.fetchMonitoringPlotsWithoutElevation(),
+          "Organization user")
+
+      assertEquals(
+          listOf(org2PlotIdModel, org1Site2PlotModel),
+          store.fetchMonitoringPlotsWithoutElevation(2),
+          "Organization user with fetch limit")
+
+      val systemUser = SystemUser(usersDao)
+      assertEquals(
+          listOf(
+              otherOrgPlotModel,
+              org2PlotIdModel,
+              org1Site2PlotModel,
+              org1Site1PlotModel2,
+              org1Site1PlotModel1),
+          systemUser.run { store.fetchMonitoringPlotsWithoutElevation() },
+          "System user")
+    }
+  }
+
+  @Nested
+  inner class UpdateMonitoringPlotElevation {
+    @Test
+    fun `updates plots within the organization for organization admin`() {
+      val secondOrganizationId = insertOrganization()
+      insertOrganizationUser(user.userId, secondOrganizationId, Role.Manager)
+
+      val otherUserId = insertUser()
+      val otherOrganizationId = insertOrganization(createdBy = otherUserId)
+      insertOrganizationUser(otherUserId, otherOrganizationId, Role.Admin)
+
+      insertPlantingSite(boundary = multiPolygon(2.0), organizationId = organizationId)
+      val org1Site1PlotId1 =
+          insertMonitoringPlot(
+              boundary = polygon(1.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 1L)
+      val org1Site1PlotId2 =
+          insertMonitoringPlot(
+              boundary = polygon(1.5),
+              elevationMeters = BigDecimal.ZERO,
+              organizationId = organizationId,
+              plotNumber = 2L)
+
+      insertPlantingSite(boundary = multiPolygon(4.0), organizationId = organizationId)
+      val org1Site2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(2.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 4L)
+
+      insertPlantingSite(boundary = multiPolygon(3.0), organizationId = secondOrganizationId)
+      val org2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(3.0),
+              elevationMeters = null,
+              organizationId = secondOrganizationId,
+              plotNumber = 1L)
+
+      insertPlantingSite(boundary = multiPolygon(10.0), organizationId = otherOrganizationId)
+      insertPlantingSiteHistory()
+      val otherOrgPlotId =
+          insertMonitoringPlot(
+              boundary = polygon(4.0),
+              elevationMeters = null,
+              organizationId = otherOrganizationId,
+              plotNumber = 1L)
+
+      val existingRows = monitoringPlotsDao.findAll()
+
+      val rowsUpdated =
+          store.updateMonitoringPlotElevation(
+              mapOf(
+                  org1Site1PlotId1 to BigDecimal.TEN,
+                  org1Site1PlotId2 to BigDecimal.TWO,
+                  org1Site2PlotId to BigDecimal.ONE,
+                  org2PlotId to BigDecimal.TEN, // User not an admin
+                  otherOrgPlotId to BigDecimal.TEN, // User not in organization
+              ))
+
+      val updatedRecords =
+          existingRows.map { row ->
+            when (row.id) {
+              org1Site1PlotId1 -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TEN))
+              org1Site1PlotId2 -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TWO))
+              org1Site2PlotId -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.ONE))
+              else -> MonitoringPlotsRecord(row)
+            }
+          }
+
+      assertTableEquals(updatedRecords, "Monitoring plots table")
+      assertEquals(3, rowsUpdated, "Rows updated")
+    }
+
+    @Test
+    fun `system user updates all plots`() {
+      val secondOrganizationId = insertOrganization()
+      insertOrganizationUser(user.userId, secondOrganizationId, Role.Admin)
+
+      val otherUserId = insertUser()
+      val otherOrganizationId = insertOrganization(createdBy = otherUserId)
+      insertOrganizationUser(otherUserId, otherOrganizationId, Role.Admin)
+
+      insertPlantingSite(boundary = multiPolygon(2.0), organizationId = organizationId)
+      val org1Site1PlotId1 =
+          insertMonitoringPlot(
+              boundary = polygon(1.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 1L)
+      val org1Site1PlotId2 =
+          insertMonitoringPlot(
+              boundary = polygon(1.5),
+              elevationMeters = BigDecimal.ZERO,
+              organizationId = organizationId,
+              plotNumber = 2L)
+
+      insertPlantingSite(boundary = multiPolygon(4.0), organizationId = organizationId)
+      val org1Site2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(2.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 4L)
+
+      insertPlantingSite(boundary = multiPolygon(3.0), organizationId = secondOrganizationId)
+      val org2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(3.0),
+              elevationMeters = null,
+              organizationId = secondOrganizationId,
+              plotNumber = 1L)
+
+      insertPlantingSite(boundary = multiPolygon(10.0), organizationId = otherOrganizationId)
+      insertPlantingSiteHistory()
+      val otherOrgPlotId =
+          insertMonitoringPlot(
+              boundary = polygon(4.0),
+              elevationMeters = null,
+              organizationId = otherOrganizationId,
+              plotNumber = 1L)
+
+      val existingRows = monitoringPlotsDao.findAll()
+
+      val systemUser = SystemUser(usersDao)
+      val rowsUpdated =
+          systemUser.run {
+            store.updateMonitoringPlotElevation(
+                mapOf(
+                    org1Site1PlotId1 to BigDecimal.TEN,
+                    org1Site1PlotId2 to BigDecimal.TWO,
+                    org1Site2PlotId to BigDecimal.ONE,
+                    org2PlotId to BigDecimal.TEN,
+                    otherOrgPlotId to BigDecimal.TEN,
+                ))
+          }
+
+      val updatedRecords =
+          existingRows.map { row ->
+            when (row.id) {
+              org1Site1PlotId1 -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TEN))
+              org1Site1PlotId2 -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TWO))
+              org1Site2PlotId -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.ONE))
+              org2PlotId -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TEN))
+              otherOrgPlotId -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TEN))
+              else -> MonitoringPlotsRecord(row)
+            }
+          }
+
+      assertTableEquals(updatedRecords, "Monitoring plots table")
+      assertEquals(5, rowsUpdated, "Rows updated")
+    }
+
+    @Test
+    fun `updates no rows if empty map`() {
+      assertEquals(0, store.updateMonitoringPlotElevation(emptyMap()), "Empty map")
+    }
+
+    @Test
+    fun `updates no rows if no permission`() {
+      val otherUserId = insertUser()
+      val otherOrganizationId = insertOrganization(createdBy = otherUserId)
+      insertOrganizationUser(otherUserId, otherOrganizationId, Role.Admin)
+      insertPlantingSite(boundary = multiPolygon(10.0), organizationId = otherOrganizationId)
+      insertPlantingSiteHistory()
+      val otherOrgPlotId =
+          insertMonitoringPlot(
+              boundary = polygon(4.0),
+              elevationMeters = null,
+              organizationId = otherOrganizationId,
+              plotNumber = 1L)
+
+      assertEquals(
+          0,
+          store.updateMonitoringPlotElevation(mapOf(otherOrgPlotId to BigDecimal.ZERO)),
+          "Not visible plot")
+    }
+  }
+}


Reviews:
user: login: sgrimm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T22:07:52Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T22:10:23Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T22:20:49Z
user: login: tommylau523
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T01:29:34Z

Review comments:
diff_hunk: @@ -0,0 +1,83 @@
+package com.terraformation.backend.daily
+
+import com.terraformation.backend.config.TerrawareServerConfig
+import com.terraformation.backend.customer.model.SystemUser
+import com.terraformation.backend.db.SRID
+import com.terraformation.backend.log.perClassLogger
+import com.terraformation.backend.time.ClockAdvancedEvent
+import com.terraformation.backend.tracking.db.PlantingSiteStore
+import com.terraformation.backend.tracking.mapbox.MapboxService
+import com.terraformation.backend.tracking.model.AssignedPlotDetails.Companion.SOUTHWEST
+import jakarta.inject.Inject
+import jakarta.inject.Named
+import org.jobrunr.scheduling.JobScheduler
+import org.jobrunr.scheduling.cron.Cron
+import org.locationtech.jts.geom.GeometryFactory
+import org.locationtech.jts.geom.PrecisionModel
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.context.event.EventListener
+
+@ConditionalOnProperty(TerrawareServerConfig.DAILY_TASKS_ENABLED_PROPERTY, matchIfMissing = true)
+@Named
+class MonitoringPlotElevationScheduler(
+    private val config: TerrawareServerConfig,
+    private val mapboxService: MapboxService,
+    private val plantingSiteStore: PlantingSiteStore,
+    private val systemUser: SystemUser,
+) {
+  private val log = perClassLogger()
+
+  @Inject
+  fun schedule(scheduler: JobScheduler) {
+    if (config.dailyTasks.enabled) {
+      scheduler.scheduleRecurrently<ObservationScheduler>(
+          javaClass.simpleName, Cron.every15minutes()) {
+            updatePlotElevation()
+          }
+    }
+  }
+
+  @Suppress("MemberVisibilityCanBePrivate") // Called by JobRunr
+  fun updatePlotElevation(limit: Int = 50) {
+    systemUser.run {
+      val plots =
+          try {
+            plantingSiteStore.fetchMonitoringPlotsWithoutElevation(limit)
+          } catch (e: Exception) {
+            log.warn("Failed to fetch monitoring plots without elevation data: ${e.message}")
+            return@run
+          }
+
+      val geometryFactory = GeometryFactory(PrecisionModel(), SRID.LONG_LAT)
+
+      val elevationByPlotId =
+          plots
+              .mapNotNull { plot ->
+                val swCorner = geometryFactory.createPoint(plot.boundary.coordinates[SOUTHWEST])
path: src/main/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationScheduler.kt
user: login: sgrimm
body: Another option: `plot.boundary.centroid()` to get the point at the center of the plot.
created_at: 2025-04-18T22:02:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,337 @@
+package com.terraformation.backend.tracking.db.plantingSiteStore
+
+import com.terraformation.backend.RunsAsDatabaseUser
+import com.terraformation.backend.TestClock
+import com.terraformation.backend.TestEventPublisher
+import com.terraformation.backend.TestSingletons
+import com.terraformation.backend.customer.db.ParentStore
+import com.terraformation.backend.customer.model.SystemUser
+import com.terraformation.backend.customer.model.TerrawareUser
+import com.terraformation.backend.db.DatabaseTest
+import com.terraformation.backend.db.IdentifierGenerator
+import com.terraformation.backend.db.default_schema.OrganizationId
+import com.terraformation.backend.db.default_schema.Role
+import com.terraformation.backend.db.tracking.tables.records.MonitoringPlotsRecord
+import com.terraformation.backend.multiPolygon
+import com.terraformation.backend.polygon
+import com.terraformation.backend.tracking.db.PlantingSiteStore
+import com.terraformation.backend.tracking.model.MonitoringPlotModel
+import java.math.BigDecimal
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+
+class PlantingSiteStoreMonitoringPlotElevationTest : DatabaseTest(), RunsAsDatabaseUser {
+  override lateinit var user: TerrawareUser
+
+  protected val clock = TestClock()
+  protected val eventPublisher = TestEventPublisher()
+  protected val store: PlantingSiteStore by lazy {
+    PlantingSiteStore(
+        clock,
+        TestSingletons.countryDetector,
+        dslContext,
+        eventPublisher,
+        IdentifierGenerator(clock, dslContext),
+        monitoringPlotsDao,
+        ParentStore(dslContext),
+        plantingSeasonsDao,
+        plantingSitesDao,
+        plantingSubzonesDao,
+        plantingZonesDao)
+  }
+
+  private lateinit var organizationId: OrganizationId
+
+  @BeforeEach
+  fun setUp() {
+    organizationId = insertOrganization()
+    insertOrganizationUser(user.userId, organizationId, Role.Admin)
+  }
+
+  @Nested
+  inner class FetchMonitoringPlotsWithoutElevation {
+    @Test
+    fun `returns only monitoring plots without elevation and within user organizations`() {
+      val secondOrganizationId = insertOrganization()
+      insertOrganizationUser(user.userId, secondOrganizationId, Role.Contributor)
+
+      val otherUserId = insertUser()
+      val otherOrganizationId = insertOrganization(createdBy = otherUserId)
+      insertOrganizationUser(otherUserId, otherOrganizationId, Role.Admin)
+
+      insertPlantingSite(boundary = multiPolygon(2.0), organizationId = organizationId)
+      val org1Site1PlotId1 =
+          insertMonitoringPlot(
+              boundary = polygon(1.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 1L)
+      val org1Site1PlotId2 =
+          insertMonitoringPlot(
+              boundary = polygon(1.5),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 2L)
+      // Not visible because it has elevation
+      insertMonitoringPlot(
+          boundary = polygon(10.0),
+          elevationMeters = BigDecimal.TEN,
+          organizationId = organizationId,
+          plotNumber = 3L)
+
+      insertPlantingSite(boundary = multiPolygon(4.0), organizationId = organizationId)
+      val org1Site2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(2.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 4L)
+
+      insertPlantingSite(boundary = multiPolygon(3.0), organizationId = secondOrganizationId)
+      val org2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(3.0),
+              elevationMeters = null,
+              organizationId = secondOrganizationId,
+              plotNumber = 1L)
+
+      insertPlantingSite(boundary = multiPolygon(10.0), organizationId = otherOrganizationId)
+      insertPlantingSiteHistory()
+      val otherOrgPlotId =
+          insertMonitoringPlot(
+              boundary = polygon(4.0),
+              elevationMeters = null,
+              organizationId = otherOrganizationId,
+              plotNumber = 1L)
+      // Not visible because it has elevation
+      insertMonitoringPlot(
+          boundary = polygon(10.0),
+          elevationMeters = BigDecimal.TWO,
+          organizationId = otherOrganizationId,
+          plotNumber = 2L)
+
+      val org1Site1PlotModel1 =
+          MonitoringPlotModel(
+              boundary = polygon(1.0),
+              elevationMeters = null,
+              id = org1Site1PlotId1,
+              isAdHoc = false,
+              isAvailable = true,
+              permanentCluster = null,
+              plotNumber = 1L,
+              sizeMeters = 30,
+          )
+      val org1Site1PlotModel2 =
+          org1Site1PlotModel1.copy(boundary = polygon(1.5), id = org1Site1PlotId2, plotNumber = 2L)
+
+      val org1Site2PlotModel =
+          org1Site1PlotModel1.copy(boundary = polygon(2.0), id = org1Site2PlotId, plotNumber = 4L)
+
+      val org2PlotIdModel =
+          org1Site1PlotModel1.copy(boundary = polygon(3.0), id = org2PlotId, plotNumber = 1L)
+
+      val otherOrgPlotModel =
+          org1Site1PlotModel1.copy(boundary = polygon(4.0), id = otherOrgPlotId, plotNumber = 1L)
+
+      assertEquals(
+          listOf(org2PlotIdModel, org1Site2PlotModel, org1Site1PlotModel2, org1Site1PlotModel1),
+          store.fetchMonitoringPlotsWithoutElevation(),
+          "Organization user")
+
+      assertEquals(
+          listOf(org2PlotIdModel, org1Site2PlotModel),
+          store.fetchMonitoringPlotsWithoutElevation(2),
+          "Organization user with fetch limit")
+
+      val systemUser = SystemUser(usersDao)
+      assertEquals(
+          listOf(
+              otherOrgPlotModel,
+              org2PlotIdModel,
+              org1Site2PlotModel,
+              org1Site1PlotModel2,
+              org1Site1PlotModel1),
+          systemUser.run { store.fetchMonitoringPlotsWithoutElevation() },
+          "System user")
+    }
+  }
+
+  @Nested
+  inner class UpdateMonitoringPlotElevation {
+    @Test
+    fun `updates plots within the organization for organization admin`() {
+      val secondOrganizationId = insertOrganization()
+      insertOrganizationUser(user.userId, secondOrganizationId, Role.Manager)
+
+      val otherUserId = insertUser()
+      val otherOrganizationId = insertOrganization(createdBy = otherUserId)
+      insertOrganizationUser(otherUserId, otherOrganizationId, Role.Admin)
+
+      insertPlantingSite(boundary = multiPolygon(2.0), organizationId = organizationId)
+      val org1Site1PlotId1 =
+          insertMonitoringPlot(
+              boundary = polygon(1.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 1L)
+      val org1Site1PlotId2 =
+          insertMonitoringPlot(
+              boundary = polygon(1.5),
+              elevationMeters = BigDecimal.ZERO,
+              organizationId = organizationId,
+              plotNumber = 2L)
+
+      insertPlantingSite(boundary = multiPolygon(4.0), organizationId = organizationId)
+      val org1Site2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(2.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 4L)
+
+      insertPlantingSite(boundary = multiPolygon(3.0), organizationId = secondOrganizationId)
+      val org2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(3.0),
+              elevationMeters = null,
+              organizationId = secondOrganizationId,
+              plotNumber = 1L)
+
+      insertPlantingSite(boundary = multiPolygon(10.0), organizationId = otherOrganizationId)
+      insertPlantingSiteHistory()
+      val otherOrgPlotId =
+          insertMonitoringPlot(
+              boundary = polygon(4.0),
+              elevationMeters = null,
+              organizationId = otherOrganizationId,
+              plotNumber = 1L)
+
+      val existingRows = monitoringPlotsDao.findAll()
+
+      val rowsUpdated =
+          store.updateMonitoringPlotElevation(
+              mapOf(
+                  org1Site1PlotId1 to BigDecimal.TEN,
+                  org1Site1PlotId2 to BigDecimal.TWO,
+                  org1Site2PlotId to BigDecimal.ONE,
+                  org2PlotId to BigDecimal.TEN, // User not an admin
+                  otherOrgPlotId to BigDecimal.TEN, // User not in organization
+              ))
+
+      val updatedRecords =
+          existingRows.map { row ->
+            when (row.id) {
+              org1Site1PlotId1 -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TEN))
+              org1Site1PlotId2 -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TWO))
+              org1Site2PlotId -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.ONE))
+              else -> MonitoringPlotsRecord(row)
+            }
+          }
+
+      assertTableEquals(updatedRecords, "Monitoring plots table")
+      assertEquals(3, rowsUpdated, "Rows updated")
+    }
+
+    @Test
+    fun `system user updates all plots`() {
+      val secondOrganizationId = insertOrganization()
+      insertOrganizationUser(user.userId, secondOrganizationId, Role.Admin)
+
+      val otherUserId = insertUser()
+      val otherOrganizationId = insertOrganization(createdBy = otherUserId)
+      insertOrganizationUser(otherUserId, otherOrganizationId, Role.Admin)
+
+      insertPlantingSite(boundary = multiPolygon(2.0), organizationId = organizationId)
+      val org1Site1PlotId1 =
+          insertMonitoringPlot(
+              boundary = polygon(1.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 1L)
+      val org1Site1PlotId2 =
+          insertMonitoringPlot(
+              boundary = polygon(1.5),
+              elevationMeters = BigDecimal.ZERO,
+              organizationId = organizationId,
+              plotNumber = 2L)
+
+      insertPlantingSite(boundary = multiPolygon(4.0), organizationId = organizationId)
+      val org1Site2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(2.0),
+              elevationMeters = null,
+              organizationId = organizationId,
+              plotNumber = 4L)
+
+      insertPlantingSite(boundary = multiPolygon(3.0), organizationId = secondOrganizationId)
+      val org2PlotId =
+          insertMonitoringPlot(
+              boundary = polygon(3.0),
+              elevationMeters = null,
+              organizationId = secondOrganizationId,
+              plotNumber = 1L)
+
+      insertPlantingSite(boundary = multiPolygon(10.0), organizationId = otherOrganizationId)
+      insertPlantingSiteHistory()
+      val otherOrgPlotId =
+          insertMonitoringPlot(
+              boundary = polygon(4.0),
+              elevationMeters = null,
+              organizationId = otherOrganizationId,
+              plotNumber = 1L)
+
+      val existingRows = monitoringPlotsDao.findAll()
+
+      val systemUser = SystemUser(usersDao)
+      val rowsUpdated =
+          systemUser.run {
+            store.updateMonitoringPlotElevation(
+                mapOf(
+                    org1Site1PlotId1 to BigDecimal.TEN,
+                    org1Site1PlotId2 to BigDecimal.TWO,
+                    org1Site2PlotId to BigDecimal.ONE,
+                    org2PlotId to BigDecimal.TEN,
+                    otherOrgPlotId to BigDecimal.TEN,
+                ))
+          }
+
+      val updatedRecords =
+          existingRows.map { row ->
+            when (row.id) {
+              org1Site1PlotId1 -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TEN))
+              org1Site1PlotId2 -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TWO))
+              org1Site2PlotId -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.ONE))
+              org2PlotId -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TEN))
+              otherOrgPlotId -> MonitoringPlotsRecord(row.copy(elevationMeters = BigDecimal.TEN))
+              else -> MonitoringPlotsRecord(row)
+            }
+          }
+
+      assertTableEquals(updatedRecords, "Monitoring plots table")
+      assertEquals(5, rowsUpdated, "Rows updated")
+    }
+
+    @Test
+    fun `updates no rows if no permission or empty map`() {
path: src/test/kotlin/com/terraformation/backend/tracking/db/plantingSiteStore/PlantingSiteStoreMonitoringPlotElevationTest.kt
user: login: sgrimm
body: This could be split into two test cases.
created_at: 2025-04-18T22:07:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,83 @@
+package com.terraformation.backend.daily
+
+import com.terraformation.backend.config.TerrawareServerConfig
+import com.terraformation.backend.customer.model.SystemUser
+import com.terraformation.backend.db.SRID
+import com.terraformation.backend.log.perClassLogger
+import com.terraformation.backend.time.ClockAdvancedEvent
+import com.terraformation.backend.tracking.db.PlantingSiteStore
+import com.terraformation.backend.tracking.mapbox.MapboxService
+import com.terraformation.backend.tracking.model.AssignedPlotDetails.Companion.SOUTHWEST
+import jakarta.inject.Inject
+import jakarta.inject.Named
+import org.jobrunr.scheduling.JobScheduler
+import org.jobrunr.scheduling.cron.Cron
+import org.locationtech.jts.geom.GeometryFactory
+import org.locationtech.jts.geom.PrecisionModel
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.context.event.EventListener
+
+@ConditionalOnProperty(TerrawareServerConfig.DAILY_TASKS_ENABLED_PROPERTY, matchIfMissing = true)
+@Named
+class MonitoringPlotElevationScheduler(
+    private val config: TerrawareServerConfig,
+    private val mapboxService: MapboxService,
+    private val plantingSiteStore: PlantingSiteStore,
+    private val systemUser: SystemUser,
+) {
+  private val log = perClassLogger()
+
+  @Inject
+  fun schedule(scheduler: JobScheduler) {
+    if (config.dailyTasks.enabled) {
+      scheduler.scheduleRecurrently<ObservationScheduler>(
+          javaClass.simpleName, Cron.every15minutes()) {
+            updatePlotElevation()
+          }
path: src/main/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationScheduler.kt
user: login: graphite-app[bot]
body: This code creates a circular dependency by incorrectly referencing ObservationScheduler instead of MonitoringPlotElevationScheduler. Change the generic type parameter from ObservationScheduler to MonitoringPlotElevationScheduler to fix the issue.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=terraware&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-18T22:10:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,10 +138,7 @@ class ParentStore(private val dslContext: DSLContext) {
       fetchFieldById(facilityId, FACILITIES.ID, FACILITIES.ORGANIZATION_ID)
 
   fun getOrganizationId(monitoringPlotId: MonitoringPlotId): OrganizationId? =
-      fetchFieldById(
-          monitoringPlotId,
-          MONITORING_PLOTS.ID,
-          MONITORING_PLOTS.plantingSubzones.plantingSites.ORGANIZATION_ID)
+      fetchFieldById(monitoringPlotId, MONITORING_PLOTS.ID, MONITORING_PLOTS.ORGANIZATION_ID)
path: src/main/kotlin/com/terraformation/backend/customer/db/ParentStore.kt
user: login: graphite-app[bot]
body: This change is causing permission checks to fail. The direct column reference MONITORING_PLOTS.ORGANIZATION_ID might not contain the same data as what was previously retrieved through the join. Revert to the previous implementation that used MONITORING_PLOTS.plantingSubzones.plantingSites.ORGANIZATION_ID to ensure consistent behavior.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=terraware&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-18T22:20:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+package com.terraformation.backend.daily
+
+import com.terraformation.backend.config.TerrawareServerConfig
+import com.terraformation.backend.customer.model.SystemUser
+import com.terraformation.backend.log.perClassLogger
+import com.terraformation.backend.time.ClockAdvancedEvent
+import com.terraformation.backend.tracking.db.PlantingSiteStore
+import com.terraformation.backend.tracking.mapbox.MapboxService
+import jakarta.inject.Inject
+import jakarta.inject.Named
+import org.jobrunr.scheduling.JobScheduler
+import org.jobrunr.scheduling.cron.Cron
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.context.event.EventListener
+
+@ConditionalOnProperty(TerrawareServerConfig.DAILY_TASKS_ENABLED_PROPERTY, matchIfMissing = true)
+@Named
+class MonitoringPlotElevationScheduler(
+    private val config: TerrawareServerConfig,
+    private val mapboxService: MapboxService,
+    private val plantingSiteStore: PlantingSiteStore,
+    private val systemUser: SystemUser,
+) {
+  private val MONITORING_PLOT_BATCH_SIZE = 50
+  private val log = perClassLogger()
+
+  @Inject
+  fun schedule(scheduler: JobScheduler) {
+    if (config.dailyTasks.enabled) {
+      scheduler.scheduleRecurrently<MonitoringPlotElevationScheduler>(
+          javaClass.simpleName, Cron.every15minutes()) {
+            updatePlotElevation(MONITORING_PLOT_BATCH_SIZE)
path: src/main/kotlin/com/terraformation/backend/daily/MonitoringPlotElevationScheduler.kt
user: login: tommylau523
body: Turns out, JobScheduler injection doesn't work with default params.  
created_at: 2025-04-19T01:29:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: tommylau523
created_at: 2025-04-18T21:53:26Z
body: * **#3043** <a href="https://app.graphite.dev/github/pr/terraware/terraware-server/3043?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/terraware/terraware-server/3043?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: tommylau523
created_at: 2025-04-19T01:46:15Z
body: ### Merge activity

* **Apr 18, 9:46 PM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/terraware/terraware-server/3043).

author_association: CONTRIBUTOR