Skip to content

Commit

Permalink
SW-4411 Rework planting site notification schema (#1474)
Browse files Browse the repository at this point in the history
We're about to add several more reminder notifications for planting sites, and
it will start to get a little unwieldy to have a separate column in the planting
sites table to keep track of each one.

Create a new table `planting_site_notifications` that tracks how many of each
type of notification we've sent for a planting site. This models the reminder
notifications a bit differently than before: they are considered to have the
same notification type but a different "notification number" where the first
notification about something is number 1, the first reminder is number 2, etc.
  • Loading branch information
sgrimm authored Nov 15, 2023
1 parent 8a36905 commit 8a315c4
Show file tree
Hide file tree
Showing 12 changed files with 231 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ val ID_WRAPPERS =
"planting_sites\\.id",
"planting_site_summaries\\.id",
".*\\.planting_site_id")),
IdWrapper("PlantingSiteNotificationId", listOf("planting_site_notifications\\.id")),
IdWrapper("PlantingZoneId", listOf("planting_zones\\.id", ".*\\.planting_zone_id")),
IdWrapper("PlantingSubzoneId", listOf("planting_subzones\\.id", ".*\\.planting_subzone_id")),
IdWrapper("RecordedPlantId", listOf("recorded_plants\\.id")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ class ObservationService(
manageNotifications()
}

plantingSiteStore.markNotificationComplete(plantingSiteId, criteria.notificationCompletedField)
plantingSiteStore.markNotificationComplete(
plantingSiteId, criteria.notificationType, criteria.notificationNumber)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.terraformation.backend.customer.model.requirePermissions
import com.terraformation.backend.db.ProjectInDifferentOrganizationException
import com.terraformation.backend.db.ProjectNotFoundException
import com.terraformation.backend.db.asNonNullable
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.forMultiset
Expand All @@ -21,10 +22,10 @@ import com.terraformation.backend.db.tracking.tables.daos.PlantingZonesDao
import com.terraformation.backend.db.tracking.tables.pojos.PlantingSitesRow
import com.terraformation.backend.db.tracking.tables.pojos.PlantingSubzonesRow
import com.terraformation.backend.db.tracking.tables.pojos.PlantingZonesRow
import com.terraformation.backend.db.tracking.tables.records.PlantingSitesRecord
import com.terraformation.backend.db.tracking.tables.references.MONITORING_PLOTS
import com.terraformation.backend.db.tracking.tables.references.PLANTINGS
import com.terraformation.backend.db.tracking.tables.references.PLANTING_SITES
import com.terraformation.backend.db.tracking.tables.references.PLANTING_SITE_NOTIFICATIONS
import com.terraformation.backend.db.tracking.tables.references.PLANTING_SITE_POPULATIONS
import com.terraformation.backend.db.tracking.tables.references.PLANTING_SUBZONES
import com.terraformation.backend.db.tracking.tables.references.PLANTING_ZONES
Expand All @@ -47,7 +48,6 @@ import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Field
import org.jooq.Record
import org.jooq.TableField
import org.jooq.impl.DSL
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
Expand Down Expand Up @@ -474,18 +474,23 @@ class PlantingSiteStore(

fun markNotificationComplete(
plantingSiteId: PlantingSiteId,
notificationProperty: TableField<PlantingSitesRecord, Instant?>
notificationType: NotificationType,
notificationNumber: Int,
) {
requirePermissions {
readPlantingSite(plantingSiteId)
manageNotifications()
}

dslContext
.update(PLANTING_SITES)
.set(notificationProperty, clock.instant())
.where(PLANTING_SITES.ID.eq(plantingSiteId))
.execute()
with(PLANTING_SITE_NOTIFICATIONS) {
dslContext
.insertInto(PLANTING_SITE_NOTIFICATIONS)
.set(PLANTING_SITE_ID, plantingSiteId)
.set(NOTIFICATION_TYPE_ID, notificationType)
.set(NOTIFICATION_NUMBER, notificationNumber)
.set(SENT_TIME, clock.instant())
.execute()
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,74 +1,89 @@
package com.terraformation.backend.tracking.model

import com.terraformation.backend.db.default_schema.NotificationType
import com.terraformation.backend.db.tracking.PlantingSiteId
import com.terraformation.backend.db.tracking.tables.records.PlantingSitesRecord
import com.terraformation.backend.db.tracking.tables.references.PLANTING_SITES
import com.terraformation.backend.db.tracking.tables.references.PLANTING_SITE_NOTIFICATIONS
import com.terraformation.backend.tracking.event.ObservationNotScheduledNotificationEvent
import com.terraformation.backend.tracking.event.ObservationSchedulingNotificationEvent
import com.terraformation.backend.tracking.event.ScheduleObservationNotificationEvent
import com.terraformation.backend.tracking.event.ScheduleObservationReminderNotificationEvent
import java.time.Instant
import org.jooq.Condition
import org.jooq.TableField
import org.jooq.impl.DSL

class NotificationCriteria {

sealed interface ObservationScheduling {
val completedTimeElapsedWeeks: Long
val firstPlantingElapsedWeeks: Long
val notificationNotCompletedCondition: Condition
val notificationCompletedField: TableField<PlantingSitesRecord, Instant?>
val notificationType: NotificationType
val notificationNumber: Int
fun notificationEvent(plantingSiteId: PlantingSiteId): ObservationSchedulingNotificationEvent

val notificationNotCompletedCondition: Condition
get() {
val thisNotificationNotSent =
DSL.notExists(
DSL.selectOne()
.from(PLANTING_SITE_NOTIFICATIONS)
.where(PLANTING_SITES.ID.eq(PLANTING_SITE_NOTIFICATIONS.PLANTING_SITE_ID))
.and(PLANTING_SITE_NOTIFICATIONS.NOTIFICATION_TYPE_ID.eq(notificationType))
.and(PLANTING_SITE_NOTIFICATIONS.NOTIFICATION_NUMBER.ge(notificationNumber)))

return if (notificationNumber > 1) {
val previousNotificationSent =
DSL.exists(
DSL.selectOne()
.from(PLANTING_SITE_NOTIFICATIONS)
.where(PLANTING_SITES.ID.eq(PLANTING_SITE_NOTIFICATIONS.PLANTING_SITE_ID))
.and(PLANTING_SITE_NOTIFICATIONS.NOTIFICATION_TYPE_ID.eq(notificationType))
.and(
PLANTING_SITE_NOTIFICATIONS.NOTIFICATION_NUMBER.eq(
notificationNumber - 1)))

DSL.and(previousNotificationSent, thisNotificationNotSent)
} else {
thisNotificationNotSent
}
}
}

object ScheduleObservations : ObservationScheduling {
data object ScheduleObservations : ObservationScheduling {
override val completedTimeElapsedWeeks: Long = 2
override val firstPlantingElapsedWeeks: Long = 0
override val notificationNotCompletedCondition: Condition =
DSL.condition(PLANTING_SITES.SCHEDULE_OBSERVATION_NOTIFICATION_SENT_TIME.isNull)
override val notificationCompletedField: TableField<PlantingSitesRecord, Instant?> =
PLANTING_SITES.SCHEDULE_OBSERVATION_NOTIFICATION_SENT_TIME
override val notificationType: NotificationType = NotificationType.ScheduleObservation
override val notificationNumber: Int = 1

override fun notificationEvent(plantingSiteId: PlantingSiteId) =
ScheduleObservationNotificationEvent(plantingSiteId)
}

object RemindSchedulingObservations : ObservationScheduling {
data object RemindSchedulingObservations : ObservationScheduling {
override val completedTimeElapsedWeeks: Long = 6
override val firstPlantingElapsedWeeks: Long = 4
override val notificationNotCompletedCondition: Condition =
DSL.condition(PLANTING_SITES.SCHEDULE_OBSERVATION_NOTIFICATION_SENT_TIME.isNotNull)
.and(PLANTING_SITES.SCHEDULE_OBSERVATION_REMINDER_NOTIFICATION_SENT_TIME.isNull)
override val notificationCompletedField: TableField<PlantingSitesRecord, Instant?> =
PLANTING_SITES.SCHEDULE_OBSERVATION_REMINDER_NOTIFICATION_SENT_TIME
override val notificationType: NotificationType = NotificationType.ScheduleObservation
override val notificationNumber: Int = 2

override fun notificationEvent(plantingSiteId: PlantingSiteId) =
ScheduleObservationReminderNotificationEvent(plantingSiteId)
}

object ObservationNotScheduledFirstNotification : ObservationScheduling {
data object ObservationNotScheduledFirstNotification : ObservationScheduling {
override val completedTimeElapsedWeeks: Long = 8
override val firstPlantingElapsedWeeks: Long = 6
override val notificationNotCompletedCondition: Condition =
DSL.condition(PLANTING_SITES.OBSERVATION_NOT_SCHEDULED_FIRST_NOTIFICATION_SENT_TIME.isNull)
override val notificationCompletedField: TableField<PlantingSitesRecord, Instant?> =
PLANTING_SITES.OBSERVATION_NOT_SCHEDULED_FIRST_NOTIFICATION_SENT_TIME
override val notificationType: NotificationType =
NotificationType.ObservationNotScheduledSupport
override val notificationNumber: Int = 1

override fun notificationEvent(plantingSiteId: PlantingSiteId) =
ObservationNotScheduledNotificationEvent(plantingSiteId)
}

object ObservationNotScheduledSecondNotification : ObservationScheduling {
data object ObservationNotScheduledSecondNotification : ObservationScheduling {
override val completedTimeElapsedWeeks: Long = 16
override val firstPlantingElapsedWeeks: Long = 14
override val notificationNotCompletedCondition: Condition =
DSL.condition(
PLANTING_SITES.OBSERVATION_NOT_SCHEDULED_FIRST_NOTIFICATION_SENT_TIME.isNotNull,
)
.and(PLANTING_SITES.OBSERVATION_NOT_SCHEDULED_SECOND_NOTIFICATION_SENT_TIME.isNull)
override val notificationCompletedField: TableField<PlantingSitesRecord, Instant?> =
PLANTING_SITES.OBSERVATION_NOT_SCHEDULED_SECOND_NOTIFICATION_SENT_TIME
override val notificationType: NotificationType =
NotificationType.ObservationNotScheduledSupport
override val notificationNumber: Int = 2

override fun notificationEvent(plantingSiteId: PlantingSiteId) =
ObservationNotScheduledNotificationEvent(plantingSiteId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
CREATE TABLE tracking.planting_site_notifications (
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
planting_site_id BIGINT NOT NULL REFERENCES tracking.planting_sites ON DELETE CASCADE,
notification_type_id INTEGER NOT NULL REFERENCES notification_types,
notification_number INTEGER NOT NULL,
sent_time TIMESTAMP WITH TIME ZONE NOT NULL,

UNIQUE (planting_site_id, notification_type_id, notification_number)
);

-- These are also in R__TypeCodes.sql
INSERT INTO notification_criticalities (id, name)
VALUES (1, 'Info')
ON CONFLICT DO NOTHING;
INSERT INTO notification_types (id, name, notification_criticality_id)
VALUES (21, 'Observation Not Scheduled (Support)', 1);

INSERT INTO tracking.planting_site_notifications
(planting_site_id, notification_type_id, notification_number, sent_time)
SELECT ps.id, nt.id, 1, ps.schedule_observation_notification_sent_time
FROM tracking.planting_sites ps, notification_types nt
WHERE ps.schedule_observation_notification_sent_time IS NOT NULL
AND nt.name = 'Schedule Observation';

INSERT INTO tracking.planting_site_notifications
(planting_site_id, notification_type_id, notification_number, sent_time)
SELECT ps.id, nt.id, 2, ps.schedule_observation_reminder_notification_sent_time
FROM tracking.planting_sites ps, notification_types nt
WHERE ps.schedule_observation_reminder_notification_sent_time IS NOT NULL
AND nt.name = 'Schedule Observation';

INSERT INTO tracking.planting_site_notifications
(planting_site_id, notification_type_id, notification_number, sent_time)
SELECT ps.id, nt.id, 1, ps.observation_not_scheduled_first_notification_sent_time
FROM tracking.planting_sites ps, notification_types nt
WHERE ps.observation_not_scheduled_first_notification_sent_time IS NOT NULL
AND nt.name = 'Observation Not Scheduled (Support)';

INSERT INTO tracking.planting_site_notifications
(planting_site_id, notification_type_id, notification_number, sent_time)
SELECT ps.id, nt.id, 2, ps.observation_not_scheduled_second_notification_sent_time
FROM tracking.planting_sites ps, notification_types nt
WHERE ps.observation_not_scheduled_second_notification_sent_time IS NOT NULL
AND nt.name = 'Observation Not Scheduled (Support)';

-- Usually we would drop columns in a separate migration to avoid breaking the old code while the
-- new release is being rolled out, but in this case we do it all in one migration.
--
-- If notifications are being generated on another instance while this migration is running, the
-- notification job will fail because we're dropping these columns. The next run will generate
-- any missing notifications.
ALTER TABLE tracking.planting_sites DROP COLUMN observation_not_scheduled_first_notification_sent_time;
ALTER TABLE tracking.planting_sites DROP COLUMN observation_not_scheduled_second_notification_sent_time;
ALTER TABLE tracking.planting_sites DROP COLUMN schedule_observation_notification_sent_time;
ALTER TABLE tracking.planting_sites DROP COLUMN schedule_observation_reminder_notification_sent_time;
3 changes: 3 additions & 0 deletions src/main/resources/db/migration/R__Comments.sql
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,9 @@ COMMENT ON COLUMN tracking.observed_zone_species_totals.cumulative_dead IS 'Tota
COMMENT ON COLUMN tracking.observed_zone_species_totals.mortality_rate IS 'Percentage of plants of the species observed in permanent monitoring plots in the planting zone, in either the current observation or in previous ones, that were dead.';
COMMENT ON COLUMN tracking.observed_zone_species_totals.permanent_live IS 'The number of live and existing plants observed in permanent monitoring plots.';

COMMENT ON TABLE tracking.planting_site_notifications IS 'Tracks which notifications have already been sent regarding planting sites.';
COMMENT ON COLUMN tracking.planting_site_notifications.notification_number IS 'Number of notifications of this type that have been sent, including this one. 1 for initial notification, 2 for reminder, 3 for second reminder, etc.';

COMMENT ON TABLE tracking.planting_site_populations IS 'Total number of plants of each species in each planting site.';

COMMENT ON TABLE tracking.planting_sites IS 'Top-level information about entire planting sites. Every planting site has at least one planting zone.';
Expand Down
3 changes: 2 additions & 1 deletion src/main/resources/db/migration/R__TypeCodes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ VALUES (1, 'User Added to Organization', 1),
(17, 'Observation Upcoming', 1),
(18, 'Observation Started', 1),
(19, 'Schedule Observation', 1),
(20, 'Schedule Observation Reminder', 1)
(20, 'Schedule Observation Reminder', 1),
(21, 'Observation Not Scheduled (Support)', 1)
ON CONFLICT (id) DO UPDATE SET name = excluded.name,
notification_criticality_id = excluded.notification_criticality_id;

Expand Down
1 change: 1 addition & 0 deletions src/main/resources/i18n/Enums_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public.NotificationType.DeviceUnresponsive=Device Unresponsive
public.NotificationType.FacilityAlertRequested=Facility Alert Requested
public.NotificationType.FacilityIdle=Facility Idle
public.NotificationType.NurserySeedlingBatchReady=Nursery Seedling Batch Ready
public.NotificationType.ObservationNotScheduledSupport=Observation Not Scheduled (Support)
public.NotificationType.ObservationStarted=Observation Started
public.NotificationType.ObservationUpcoming=Observation Upcoming
public.NotificationType.ReportCreated=Report Created
Expand Down
29 changes: 29 additions & 0 deletions src/test/kotlin/com/terraformation/backend/db/DatabaseTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import com.terraformation.backend.db.tracking.ObservationState
import com.terraformation.backend.db.tracking.ObservedPlotCoordinatesId
import com.terraformation.backend.db.tracking.PlantingId
import com.terraformation.backend.db.tracking.PlantingSiteId
import com.terraformation.backend.db.tracking.PlantingSiteNotificationId
import com.terraformation.backend.db.tracking.PlantingSubzoneId
import com.terraformation.backend.db.tracking.PlantingType
import com.terraformation.backend.db.tracking.PlantingZoneId
Expand All @@ -119,6 +120,7 @@ import com.terraformation.backend.db.tracking.tables.daos.ObservationPlotConditi
import com.terraformation.backend.db.tracking.tables.daos.ObservationPlotsDao
import com.terraformation.backend.db.tracking.tables.daos.ObservationsDao
import com.terraformation.backend.db.tracking.tables.daos.ObservedPlotCoordinatesDao
import com.terraformation.backend.db.tracking.tables.daos.PlantingSiteNotificationsDao
import com.terraformation.backend.db.tracking.tables.daos.PlantingSitePopulationsDao
import com.terraformation.backend.db.tracking.tables.daos.PlantingSitesDao
import com.terraformation.backend.db.tracking.tables.daos.PlantingSubzonePopulationsDao
Expand All @@ -133,6 +135,7 @@ import com.terraformation.backend.db.tracking.tables.pojos.ObservationPhotosRow
import com.terraformation.backend.db.tracking.tables.pojos.ObservationPlotsRow
import com.terraformation.backend.db.tracking.tables.pojos.ObservationsRow
import com.terraformation.backend.db.tracking.tables.pojos.ObservedPlotCoordinatesRow
import com.terraformation.backend.db.tracking.tables.pojos.PlantingSiteNotificationsRow
import com.terraformation.backend.db.tracking.tables.pojos.PlantingSitePopulationsRow
import com.terraformation.backend.db.tracking.tables.pojos.PlantingSitesRow
import com.terraformation.backend.db.tracking.tables.pojos.PlantingSubzonePopulationsRow
Expand Down Expand Up @@ -331,6 +334,7 @@ abstract class DatabaseTest {
protected val organizationsDao: OrganizationsDao by lazyDao()
protected val organizationUsersDao: OrganizationUsersDao by lazyDao()
protected val plantingsDao: PlantingsDao by lazyDao()
protected val plantingSiteNotificationsDao: PlantingSiteNotificationsDao by lazyDao()
protected val plantingSitePopulationsDao: PlantingSitePopulationsDao by lazyDao()
protected val plantingSitesDao: PlantingSitesDao by lazyDao()
protected val plantingSubzonePopulationsDao: PlantingSubzonePopulationsDao by lazyDao()
Expand Down Expand Up @@ -1001,6 +1005,28 @@ abstract class DatabaseTest {
return rowWithDefaults.id!!.also { inserted.plantingSiteIds.add(it) }
}

fun insertPlantingSiteNotification(
row: PlantingSiteNotificationsRow = PlantingSiteNotificationsRow(),
id: Any? = row.id,
number: Int = row.notificationNumber ?: 1,
plantingSiteId: Any = row.plantingSiteId ?: inserted.plantingSiteId,
sentTime: Instant = row.sentTime ?: Instant.EPOCH,
type: NotificationType,
): PlantingSiteNotificationId {
val rowWithDefaults =
row.copy(
id = id?.toIdWrapper { PlantingSiteNotificationId(it) },
notificationNumber = number,
notificationTypeId = type,
plantingSiteId = plantingSiteId.toIdWrapper { PlantingSiteId(it) },
sentTime = sentTime,
)

plantingSiteNotificationsDao.insert(rowWithDefaults)

return rowWithDefaults.id!!.also { inserted.plantingSiteNotificationIds.add(it) }
}

private var nextPlantingZoneNumber: Int = 1

fun insertPlantingZone(
Expand Down Expand Up @@ -1460,6 +1486,7 @@ abstract class DatabaseTest {
val organizationIds = mutableListOf<OrganizationId>()
val plantingIds = mutableListOf<PlantingId>()
val plantingSiteIds = mutableListOf<PlantingSiteId>()
val plantingSiteNotificationIds = mutableListOf<PlantingSiteNotificationId>()
val plantingSubzoneIds = mutableListOf<PlantingSubzoneId>()
val plantingZoneIds = mutableListOf<PlantingZoneId>()
val projectIds = mutableListOf<ProjectId>()
Expand Down Expand Up @@ -1496,6 +1523,8 @@ abstract class DatabaseTest {
get() = plantingIds.last()
val plantingSiteId
get() = plantingSiteIds.last()
val plantingSiteNotificationId
get() = plantingSiteNotificationIds.last()
val plantingSubzoneId
get() = plantingSubzoneIds.last()
val plantingZoneId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ class SchemaDocsGenerator : DatabaseTest() {
"observed_site_species_totals" to setOf(ALL, TRACKING),
"observed_zone_species_totals" to setOf(ALL, TRACKING),
"planting_types" to setOf(ALL, TRACKING),
"planting_site_notifications" to setOf(ALL, TRACKING),
"planting_site_populations" to setOf(ALL, TRACKING),
"planting_sites" to setOf(ALL, TRACKING),
"planting_zone_populations" to setOf(ALL, TRACKING),
Expand Down
Loading

0 comments on commit 8a315c4

Please sign in to comment.