Skip to content

Commit b6887ed

Browse files
authored
HAI-3538 Send a reminder email when a draft hanke has been unmodified for a long time (#1134)
* HAI-3538 Send a reminder email when a draft hanke has been unmodified for 175 or 165 days (either limit, not both).
1 parent 7911517 commit b6887ed

16 files changed

Lines changed: 833 additions & 52 deletions

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ services:
120120
HAITATON_FEATURE_HANKE_COMPLETION: ${HAITATON_FEATURE_HANKE_COMPLETION:-true}
121121
HAITATON_API_DISABLED: ${HAITATON_API_DISABLED:-false}
122122
HAITATON_SENTRY_DSN: ${HAITATON_SENTRY_DSN:-}
123+
HAITATON_COMPLETIONS_UNMODIFIED_DRAFT_REMINDER_CRON: ${HAITATON_COMPLETIONS_UNMODIFIED_DRAFT_REMINDER_CRON:-03 41 21 * * *}
123124
depends_on:
124125
db:
125126
condition: service_healthy

services/hanke-service/build.gradle.kts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,14 @@ dependencies {
7878
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
7979
implementation("com.fasterxml.jackson.module:jackson-module-jaxb-annotations")
8080
implementation("io.github.microutils:kotlin-logging:3.0.5")
81+
implementation("ch.qos.logback:logback-core:1.5.19")
8182
implementation("ch.qos.logback.access:logback-access-tomcat:2.0.6")
8283
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
8384
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
8485
implementation("de.grundid.opendatalab:geojson-jackson:1.14")
8586
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
86-
implementation("org.liquibase:liquibase-core")
87+
implementation("org.liquibase:liquibase-core:5.0.0")
88+
implementation("org.apache.commons:commons-lang3:3.19.0")
8789
implementation("com.github.blagerweij:liquibase-sessionlock:1.6.9")
8890
implementation("io.hypersistence:hypersistence-utils-hibernate-63:3.11.0")
8991
implementation("net.pwall.mustache:kotlin-mustache:0.12")
@@ -118,6 +120,10 @@ dependencies {
118120
testImplementation("org.testcontainers:junit-jupiter")
119121
testImplementation("org.testcontainers:postgresql")
120122

123+
// Override commons-compress to fix CVE
124+
testImplementation("org.apache.commons:commons-compress:1.26.0")
125+
testImplementation("commons-codec:commons-codec:1.17.2")
126+
121127
// Spring Boot Management
122128
implementation("org.springframework.boot:spring-boot-starter-actuator")
123129
implementation("org.springframework.boot:spring-boot-starter-security")

services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/HankeCompletionServiceITest.kt

Lines changed: 215 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ import org.junit.jupiter.params.provider.EnumSource
5757
import org.junit.jupiter.params.provider.ValueSource
5858
import org.springframework.beans.factory.annotation.Autowired
5959

60+
private const val EMAIL_PERUSTAJA = "pertti@perustaja.test"
61+
private const val EMAIL_OMISTAJA = "olivia.omistaja@mail.com"
62+
private const val EMAIL_RAKENNUTTAJA = "rane.rakennuttaja@mail.com"
63+
private const val EMAIL_ASIANHOITAJA = "anssi.asianhoitaja@mail.com"
64+
6065
class HankeCompletionServiceITest(
6166
@Autowired private val hankeCompletionService: HankeCompletionService,
6267
@Autowired private val hakemusFactory: HakemusFactory,
@@ -411,6 +416,76 @@ class HankeCompletionServiceITest(
411416
}
412417
}
413418

419+
@Nested
420+
inner class IdsForUnmodifiedDraftReminders {
421+
private fun saveHanke(
422+
status: HankeStatus = HankeStatus.DRAFT,
423+
modifiedDaysAgo: Long = DAYS_BEFORE_COMPLETING_DRAFT - 5,
424+
modifier: HankeBuilder.() -> HankeBuilder = { this },
425+
) =
426+
hankeFactory.builder().modifier().saveEntity(status) {
427+
it.modifiedAt = getCurrentTimeUTCAsLocalTime().minusDays(modifiedDaysAgo)
428+
}
429+
430+
@EnumSource(HankeReminder::class, names = ["DRAFT_COMPLETION_15", "DRAFT_COMPLETION_5"])
431+
@ParameterizedTest
432+
fun `returns empty list when there are no hanke`(reminder: HankeReminder) {
433+
val result = hankeCompletionService.idsForUnmodifiedDraftReminders(reminder)
434+
435+
assertThat(result).isEmpty()
436+
}
437+
438+
@EnumSource(HankeReminder::class, names = ["DRAFT_COMPLETION_15", "DRAFT_COMPLETION_5"])
439+
@ParameterizedTest
440+
fun `returns only draft hanke`(reminder: HankeReminder) {
441+
saveHanke(status = HankeStatus.COMPLETED)
442+
val draft = saveHanke()
443+
saveHanke(status = HankeStatus.PUBLIC)
444+
445+
val result = hankeCompletionService.idsForUnmodifiedDraftReminders(reminder)
446+
447+
assertThat(result).containsExactly(draft.id)
448+
}
449+
450+
@EnumSource(HankeReminder::class, names = ["DRAFT_COMPLETION_15", "DRAFT_COMPLETION_5"])
451+
@ParameterizedTest
452+
fun `returns only hanke with missing area end dates`(reminder: HankeReminder) {
453+
val noAreas = saveHanke { withNoAreas() }
454+
saveHanke {
455+
withHankealue()
456+
withHankealue(haittaLoppuPvm = ZonedDateTime.now().minusMonths(7))
457+
}
458+
val missingEndDate = saveHanke {
459+
withHankealue()
460+
withHankealue(haittaLoppuPvm = null)
461+
}
462+
463+
val result = hankeCompletionService.idsForUnmodifiedDraftReminders(reminder)
464+
465+
assertThat(result).containsExactlyInAnyOrder(noAreas.id, missingEndDate.id)
466+
}
467+
468+
@EnumSource(HankeReminder::class, names = ["DRAFT_COMPLETION_15", "DRAFT_COMPLETION_5"])
469+
@ParameterizedTest
470+
fun `returns only hanke that haven't been modified in a long time`(
471+
reminder: HankeReminder
472+
) {
473+
val modifiedDaysAgo =
474+
when (reminder) {
475+
HankeReminder.DRAFT_COMPLETION_15 -> DAYS_BEFORE_COMPLETING_DRAFT - 15
476+
HankeReminder.DRAFT_COMPLETION_5 -> DAYS_BEFORE_COMPLETING_DRAFT - 5
477+
else -> throw IllegalArgumentException("Unexpected reminder $reminder")
478+
}
479+
val onTheDay = saveHanke(modifiedDaysAgo = modifiedDaysAgo)
480+
saveHanke(modifiedDaysAgo = modifiedDaysAgo - 1)
481+
val beforeTheDay = saveHanke(modifiedDaysAgo = modifiedDaysAgo + 1)
482+
483+
val result = hankeCompletionService.idsForUnmodifiedDraftReminders(reminder)
484+
485+
assertThat(result).containsExactly(beforeTheDay.id, onTheDay.id)
486+
}
487+
}
488+
414489
@Nested
415490
inner class IdsForDraftsToComplete {
416491
private fun saveHanke(
@@ -602,10 +677,10 @@ class HankeCompletionServiceITest(
602677
assertThat(recipients).hasSize(4)
603678
assertThat(recipients)
604679
.containsExactlyInAnyOrder(
605-
"pertti@perustaja.test",
606-
"olivia.omistaja@mail.com",
607-
"rane.rakennuttaja@mail.com",
608-
"anssi.asianhoitaja@mail.com",
680+
EMAIL_PERUSTAJA,
681+
EMAIL_OMISTAJA,
682+
EMAIL_RAKENNUTTAJA,
683+
EMAIL_ASIANHOITAJA,
609684
)
610685
val email = emails.first()
611686
assertThat(email.subject)
@@ -731,11 +806,7 @@ class HankeCompletionServiceITest(
731806
val recipients = greenMail.receivedMessages.map { it.allRecipients.single().toString() }
732807
assertThat(recipients).hasSize(3)
733808
assertThat(recipients)
734-
.containsExactlyInAnyOrder(
735-
"pertti@perustaja.test",
736-
"omistaja@test",
737-
"toteuttaja@test",
738-
)
809+
.containsExactlyInAnyOrder(EMAIL_PERUSTAJA, "omistaja@test", "toteuttaja@test")
739810
}
740811

741812
@Test
@@ -841,7 +912,7 @@ class HankeCompletionServiceITest(
841912
assertThat(updatedHanke.sentReminders).containsExactly(HankeReminder.COMPLETION_5)
842913
val email = greenMail.firstReceivedMessage()
843914
assertThat(email.allRecipients).hasSize(1)
844-
assertThat(email.allRecipients[0].toString()).isEqualTo("pertti@perustaja.test")
915+
assertThat(email.allRecipients[0].toString()).isEqualTo(EMAIL_PERUSTAJA)
845916
assertThat(email.subject)
846917
.isEqualTo(
847918
"Haitaton: Hankkeesi ${hanke.hankeTunnus} päättymispäivä lähenee " +
@@ -872,7 +943,7 @@ class HankeCompletionServiceITest(
872943
assertThat(updatedHanke.sentReminders).containsExactly(HankeReminder.COMPLETION_5)
873944
val email = greenMail.firstReceivedMessage()
874945
assertThat(email.allRecipients).hasSize(1)
875-
assertThat(email.allRecipients[0].toString()).isEqualTo("pertti@perustaja.test")
946+
assertThat(email.allRecipients[0].toString()).isEqualTo(EMAIL_PERUSTAJA)
876947
assertThat(email.subject)
877948
.isEqualTo(
878949
"Haitaton: Hankkeesi ${hanke.hankeTunnus} päättymispäivä lähenee " +
@@ -1009,7 +1080,7 @@ class HankeCompletionServiceITest(
10091080
assertThat(updatedHanke.sentReminders).containsExactly(HankeReminder.COMPLETION_14)
10101081
val email = greenMail.firstReceivedMessage()
10111082
assertThat(email.allRecipients).hasSize(1)
1012-
assertThat(email.allRecipients[0].toString()).isEqualTo("pertti@perustaja.test")
1083+
assertThat(email.allRecipients[0].toString()).isEqualTo(EMAIL_PERUSTAJA)
10131084
assertThat(email.subject)
10141085
.isEqualTo(
10151086
"Haitaton: Hankkeesi ${hanke.hankeTunnus} päättymispäivä lähenee " +
@@ -1043,7 +1114,7 @@ class HankeCompletionServiceITest(
10431114
assertThat(updatedHanke.sentReminders).containsExactly(HankeReminder.COMPLETION_14)
10441115
val email = greenMail.firstReceivedMessage()
10451116
assertThat(email.allRecipients).hasSize(1)
1046-
assertThat(email.allRecipients[0].toString()).isEqualTo("pertti@perustaja.test")
1117+
assertThat(email.allRecipients[0].toString()).isEqualTo(EMAIL_PERUSTAJA)
10471118
assertThat(email.subject)
10481119
.isEqualTo(
10491120
"Haitaton: Hankkeesi ${hanke.hankeTunnus} päättymispäivä lähenee " +
@@ -1292,7 +1363,7 @@ class HankeCompletionServiceITest(
12921363
assertThat(updatedHanke.sentReminders).containsExactly(HankeReminder.DELETION_5)
12931364
val email = greenMail.firstReceivedMessage()
12941365
assertThat(email.allRecipients).hasSize(1)
1295-
assertThat(email.allRecipients[0].toString()).isEqualTo("pertti@perustaja.test")
1366+
assertThat(email.allRecipients[0].toString()).isEqualTo(EMAIL_PERUSTAJA)
12961367
assertThat(email.subject)
12971368
.isEqualTo(
12981369
"Haitaton: Hankkeesi ${hanke.hankeTunnus} poistetaan järjestelmästä " +
@@ -1356,11 +1427,7 @@ class HankeCompletionServiceITest(
13561427
val recipients = greenMail.receivedMessages.map { it.allRecipients.single().toString() }
13571428
assertThat(recipients).hasSize(3)
13581429
assertThat(recipients)
1359-
.containsExactlyInAnyOrder(
1360-
"pertti@perustaja.test",
1361-
"omistaja@test",
1362-
"toteuttaja@test",
1363-
)
1430+
.containsExactlyInAnyOrder(EMAIL_PERUSTAJA, "omistaja@test", "toteuttaja@test")
13641431
}
13651432
}
13661433

@@ -1477,10 +1544,10 @@ class HankeCompletionServiceITest(
14771544
assertThat(recipients).hasSize(4)
14781545
assertThat(recipients)
14791546
.containsExactlyInAnyOrder(
1480-
"pertti@perustaja.test",
1481-
"olivia.omistaja@mail.com",
1482-
"rane.rakennuttaja@mail.com",
1483-
"anssi.asianhoitaja@mail.com",
1547+
EMAIL_PERUSTAJA,
1548+
EMAIL_OMISTAJA,
1549+
EMAIL_RAKENNUTTAJA,
1550+
EMAIL_ASIANHOITAJA,
14841551
)
14851552
val email = emails.first()
14861553
assertThat(email.subject)
@@ -1496,6 +1563,131 @@ class HankeCompletionServiceITest(
14961563
}
14971564
}
14981565

1566+
@Nested
1567+
inner class SendUnmodifiedDraftReminderIfNecessary {
1568+
@Test
1569+
fun `does nothing when hanke has already been sent this reminder`() {
1570+
val hanke =
1571+
hankeFactory.builder().saveEntity(HankeStatus.DRAFT) {
1572+
it.sentReminders += HankeReminder.DRAFT_COMPLETION_15
1573+
}
1574+
1575+
hankeCompletionService.sendUnmodifiedDraftReminderIfNecessary(
1576+
hanke.id,
1577+
HankeReminder.DRAFT_COMPLETION_15,
1578+
)
1579+
1580+
assertThat(greenMail.receivedMessages).isEmpty()
1581+
val result = hankeRepository.getReferenceById(hanke.id)
1582+
assertThat(result.sentReminders).containsExactly(HankeReminder.DRAFT_COMPLETION_15)
1583+
}
1584+
1585+
@Test
1586+
fun `marks reminder as sent and sends emails for DRAFT_COMPLETION_15`() {
1587+
val hanke =
1588+
hankeFactory.builder().withNoAreas().saveWithYhteystiedot {
1589+
omistaja(Kayttooikeustaso.KAIKKI_OIKEUDET)
1590+
rakennuttaja(Kayttooikeustaso.KAIKKIEN_MUOKKAUS)
1591+
toteuttaja(Kayttooikeustaso.KATSELUOIKEUS)
1592+
}
1593+
hanke.status = HankeStatus.DRAFT
1594+
hankeRepository.save(hanke)
1595+
1596+
hankeCompletionService.sendUnmodifiedDraftReminderIfNecessary(
1597+
hanke.id,
1598+
HankeReminder.DRAFT_COMPLETION_15,
1599+
)
1600+
1601+
val result = hankeRepository.getReferenceById(hanke.id)
1602+
assertThat(result.sentReminders).containsExactly(HankeReminder.DRAFT_COMPLETION_15)
1603+
1604+
val emails = greenMail.receivedMessages
1605+
assertThat(emails).hasSize(3)
1606+
val recipients = emails.map { it.allRecipients.single().toString() }
1607+
assertThat(recipients)
1608+
.containsExactlyInAnyOrder(EMAIL_PERUSTAJA, EMAIL_OMISTAJA, EMAIL_RAKENNUTTAJA)
1609+
1610+
val email = emails.first()
1611+
assertThat(email.subject)
1612+
.contains(
1613+
"Haitaton: Luonnos-tilainen hankkeesi ${hanke.hankeTunnus} on ollut pitkään muokkaamattomana"
1614+
)
1615+
assertThat(email.textBody())
1616+
.contains(
1617+
"Hankkeesi ${hanke.nimi} (${hanke.hankeTunnus}) on luonnos-tilassa",
1618+
"165 vuorokauteen",
1619+
"180 vuorokautta",
1620+
)
1621+
}
1622+
1623+
@Test
1624+
fun `marks reminder as sent and sends emails for DRAFT_COMPLETION_5`() {
1625+
val hanke =
1626+
hankeFactory.builder().withNoAreas().saveWithYhteystiedot {
1627+
omistaja(Kayttooikeustaso.KAIKKI_OIKEUDET)
1628+
rakennuttaja(Kayttooikeustaso.KAIKKIEN_MUOKKAUS)
1629+
}
1630+
hanke.status = HankeStatus.DRAFT
1631+
hankeRepository.save(hanke)
1632+
1633+
hankeCompletionService.sendUnmodifiedDraftReminderIfNecessary(
1634+
hanke.id,
1635+
HankeReminder.DRAFT_COMPLETION_5,
1636+
)
1637+
1638+
val result = hankeRepository.getReferenceById(hanke.id)
1639+
assertThat(result.sentReminders).containsExactly(HankeReminder.DRAFT_COMPLETION_5)
1640+
1641+
val emails = greenMail.receivedMessages
1642+
assertThat(emails).hasSize(3)
1643+
val recipients = emails.map { it.allRecipients.single().toString() }
1644+
assertThat(recipients)
1645+
.containsExactlyInAnyOrder(EMAIL_PERUSTAJA, EMAIL_OMISTAJA, EMAIL_RAKENNUTTAJA)
1646+
1647+
val email = emails.first()
1648+
assertThat(email.subject)
1649+
.contains(
1650+
"Haitaton: Luonnos-tilainen hankkeesi ${hanke.hankeTunnus} on ollut pitkään muokkaamattomana"
1651+
)
1652+
assertThat(email.textBody())
1653+
.contains(
1654+
"Hankkeesi ${hanke.nimi} (${hanke.hankeTunnus}) on luonnos-tilassa",
1655+
"175 vuorokauteen",
1656+
"180 vuorokautta",
1657+
)
1658+
}
1659+
1660+
@Test
1661+
fun `sends emails only to users with EDIT permissions`() {
1662+
val hanke =
1663+
hankeFactory.builder().withNoAreas().saveWithYhteystiedot {
1664+
omistaja(Kayttooikeustaso.KAIKKI_OIKEUDET)
1665+
rakennuttaja(Kayttooikeustaso.KAIKKIEN_MUOKKAUS)
1666+
toteuttaja(Kayttooikeustaso.KATSELUOIKEUS)
1667+
muuYhteystieto(Kayttooikeustaso.HANKEMUOKKAUS)
1668+
toteuttaja(Kayttooikeustaso.HAKEMUSASIOINTI)
1669+
}
1670+
hanke.status = HankeStatus.DRAFT
1671+
hankeRepository.save(hanke)
1672+
1673+
hankeCompletionService.sendUnmodifiedDraftReminderIfNecessary(
1674+
hanke.id,
1675+
HankeReminder.DRAFT_COMPLETION_15,
1676+
)
1677+
1678+
val emails = greenMail.receivedMessages
1679+
val recipients = emails.map { it.allRecipients.single().toString() }
1680+
assertThat(recipients).hasSize(4)
1681+
assertThat(recipients)
1682+
.containsExactlyInAnyOrder(
1683+
EMAIL_OMISTAJA,
1684+
EMAIL_RAKENNUTTAJA,
1685+
EMAIL_ASIANHOITAJA,
1686+
EMAIL_PERUSTAJA,
1687+
)
1688+
}
1689+
}
1690+
14991691
companion object {
15001692

15011693
@JvmField

services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/HankeServiceITests.kt

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -131,32 +131,6 @@ class HankeServiceITests(
131131
@Autowired private val alluClient: AlluClient,
132132
) : IntegrationTest() {
133133

134-
companion object {
135-
data class Bounds(val minX: Double, val minY: Double, val maxX: Double, val maxY: Double) {
136-
val width: Double
137-
get() = maxX - minX
138-
139-
fun westOfOutside(): Bounds = copy(minX = minX - width - 1.0, maxX = minX - 1.0)
140-
141-
fun halfWidthToEast(): Bounds =
142-
copy(minX = minX + width / 2.0, maxY = maxX + width / 2.0)
143-
}
144-
145-
/** Default bounds envelope default geometry in hankeGeometriat.json. */
146-
val DEFAULT_BOUNDS =
147-
Bounds(minX = 25496696.0, minY = 6673077.0, maxX = 25496812.0, maxY = 6673046.0)
148-
149-
/**
150-
* Large bounds envelope that contains two geometries in polygon.json and thirdPolygon.json.
151-
*/
152-
val LARGE_BOUNDS =
153-
Bounds(minX = 25493597.0, minY = 6679731.0, maxX = 25494132.0, maxY = 6679914.0)
154-
155-
/** Medium bounds envelope that contains one geometry in polygon.json. */
156-
val MEDIUM_BOUNDS =
157-
Bounds(minX = 25493939.0, minY = 6679757.0, maxX = 25494131.0, maxY = 6679914.0)
158-
}
159-
160134
@BeforeEach
161135
fun clearMocks() {
162136
clearAllMocks()

0 commit comments

Comments
 (0)