Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6336368
PDJB-268: Add joint landlord invitation expiry scheduled task and not…
Bill-Haigh May 29, 2026
c654ca1
PDJB-268: Set Notify template IDs for joint landlord invitation expir…
Bill-Haigh May 29, 2026
20f911c
PDJB-268: trailing whitespace
Bill-Haigh May 29, 2026
9e90963
PDJB-268: Address review - use task stereotype and tidy expiry email
Bill-Haigh May 29, 2026
03bc15d
PDJB-268: Revert expiry email subject to match Notify template
Bill-Haigh May 29, 2026
a39fa17
PDJB-268: Use an expiry flag for expired JL invitations
samyou-softwire Jun 1, 2026
b98fc8c
PDJB-268: Note JL inv expiry in days
samyou-softwire Jun 1, 2026
65e8c77
PDJB-268: Remove unneeded orchestration from email regular jobs
samyou-softwire Jun 1, 2026
94a6d40
PDJB-268: Test explicitly the flag-off implementation does nothing
samyou-softwire Jun 2, 2026
6f8c759
PDJB-268: Explain why we have literal strings
samyou-softwire Jun 2, 2026
1833f5d
Revert "PDJB-268: Remove unneeded orchestration from email regular jobs"
samyou-softwire Jun 2, 2026
1f0a64e
PDJB-928: Print IDs of expired invitations
samyou-softwire Jun 2, 2026
7e2b6d9
PDJB-928: Rename flag for expiry email sending
samyou-softwire Jun 2, 2026
1fe4270
Merge branch 'main' into feat/PDJB-268-jl-invite-expiry-and-email
samyou-softwire Jun 5, 2026
d73dd7d
PDJB-268: Ensure only expired emails have emails sent
samyou-softwire Jun 5, 2026
ff6eda2
PDJB-268: Mark more appropriate todo ticket for the landlord split
samyou-softwire Jun 5, 2026
5f00cc8
PDJB-268: Rename services to reflect email sending over expiry
samyou-softwire Jun 5, 2026
81e9a42
PDJB-268: Fix migration version
samyou-softwire Jun 5, 2026
b5a0152
PDJB-268: Fix tests
samyou-softwire Jun 5, 2026
e36cabc
PDJB-268: Shorten scheduled task name
samyou-softwire Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package uk.gov.communities.prsdb.webapp.application

import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.boot.SpringApplication
import org.springframework.context.ApplicationContext
import uk.gov.communities.prsdb.webapp.annotations.taskAnnotations.PrsdbScheduledTask
import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryEmailService
import kotlin.system.exitProcess

@PrsdbScheduledTask("jl-invitation-expiry-email-scheduled-task")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't cross checked myself, but just confirming that the first part needs to match the name in the infra PR

class JointLandlordInvitationExpiryEmailTaskApplicationRunner(
private val context: ApplicationContext,
private val jointLandlordInvitationExpiryEmailService: JointLandlordInvitationExpiryEmailService,
) : ApplicationRunner {
override fun run(args: ApplicationArguments?) {
println("Executing joint landlord invitation expiry email scheduled task")

// Separating into its own method to allow this to be tested without "exitProcess" being called
sendJointLandlordInvitationExpiryEmailsTaskLogic()

val code =
SpringApplication.exit(context, { 0 }).also {
println("Scheduled task executed. Application will exit now.")
}
exitProcess(code)
}

private fun sendJointLandlordInvitationExpiryEmailsTaskLogic() {
val processedIds = jointLandlordInvitationExpiryEmailService.sendExpiryEmailsForExpiredInvitations()

processedIds.forEach { id ->
println("Sent expiry email for joint landlord invitation with id: $id")
}

println("Sent expiry emails for ${processedIds.size} joint landlord invitations.")
}

companion object {
const val SEND_JOINT_LANDLORD_INVITATION_EXPIRY_EMAILS_TASK_METHOD_NAME = "sendJointLandlordInvitationExpiryEmailsTaskLogic"
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package uk.gov.communities.prsdb.webapp.constants

const val LOCAL_COUNCIL_INVITATION_LIFETIME_IN_HOURS: Int = 48
const val JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS = 28
const val JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS: Int = 28
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ class JointLandlordInvitation(
lateinit var invitingLandlord: Landlord
private set

@Column(nullable = false)
var invitationExpiredEmailSent: Boolean = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be useful to have this as a date that the email was sent (that's what we save in ReminderEmailSent) in case this is useful for tracking (it could be null if nothing has been sent).

But I don't think anyone has asked for this so it's probably fine!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too fussed - notify will also be logging this as well as our own logs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the first actually mutable field on the entity, which means we should probably be extending ModifiableAuditableEntity instead of just AuditableEntity

private set

fun markAsExpiredEmailSent() {
invitationExpiredEmailSent = true
}

constructor(
token: UUID,
email: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package uk.gov.communities.prsdb.webapp.database.repository

import org.springframework.data.jpa.repository.JpaRepository
import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation
import java.time.Instant
import java.util.UUID

interface JointLandlordInvitationRepository : JpaRepository<JointLandlordInvitation, Long> {
fun findByToken(token: UUID): JointLandlordInvitation?

fun findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoff: Instant): List<JointLandlordInvitation>
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ enum class EmailTemplate {
LOCAL_COUNCIL_USER_INVITATION_INFORM_ADMIN_EMAIL,
INCOMPLETE_PROPERTY_REMINDER_EMAIL,
JOINT_LANDLORD_INVITATION_EMAIL,
JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package uk.gov.communities.prsdb.webapp.models.viewModels.emailModels

import java.net.URI

data class JointLandlordInvitationExpiryEmail(
val recipientName: String,
val invitedEmail: String,
val propertyAddress: String,
val propertyRecordUri: URI,
val expiryDays: Int,
) : EmailTemplateModel {
private val recipientNameKey = "recipientName"
private val invitedEmailKey = "invitedEmail"
private val propertyAddressKey = "propertyAddress"
private val propertyRecordUrlKey = "propertyRecordUrl"
private val expiryDaysKey = "expiryDays"

override val template = EmailTemplate.JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL

override fun toHashMap(): HashMap<String, String> =
hashMapOf(
recipientNameKey to recipientName,
invitedEmailKey to invitedEmail,
propertyAddressKey to propertyAddress,
propertyRecordUrlKey to propertyRecordUri.toString(),
expiryDaysKey to expiryDays.toString(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ class AbsoluteUrlProvider(
.toUri()
}

fun buildLandlordPropertyDetailsUri(propertyOwnershipId: Long): URI =
uriFromMethodCall(on(PropertyDetailsController::class.java).getPropertyDetails(propertyOwnershipId))

private fun uriFromMethodCall(info: Any): URI {
val methodCallUriComponents =
MvcUriComponentsBuilder
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package uk.gov.communities.prsdb.webapp.services

import org.springframework.context.annotation.Primary
import uk.gov.communities.prsdb.webapp.annotations.taskAnnotations.PrsdbTaskService
import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbFlip
import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORDS
import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS
import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation
import uk.gov.communities.prsdb.webapp.database.entity.Landlord
import uk.gov.communities.prsdb.webapp.database.entity.PropertyOwnership
import uk.gov.communities.prsdb.webapp.database.repository.JointLandlordInvitationRepository
import uk.gov.communities.prsdb.webapp.exceptions.PersistentEmailSendException
import uk.gov.communities.prsdb.webapp.exceptions.TransientEmailSentException
import uk.gov.communities.prsdb.webapp.models.viewModels.emailModels.JointLandlordInvitationExpiryEmail
import java.time.Instant
import java.time.temporal.ChronoUnit

@PrsdbFlip(name = JOINT_LANDLORDS, alterBean = "joint-landlord-invitation-expiry-email-flag-on")
interface JointLandlordInvitationExpiryEmailService {
fun sendExpiryEmailsForExpiredInvitations(): List<Long>
}

@Primary
@PrsdbTaskService("joint-landlord-invitation-expiry-email-flag-off")
class JointLandlordInvitationExpiryEmailServiceImplFlagOff : JointLandlordInvitationExpiryEmailService {
override fun sendExpiryEmailsForExpiredInvitations(): List<Long> {
// No-op: the joint-landlords feature is disabled, so we do not send expiry emails.
return emptyList()
}
}

@PrsdbTaskService("joint-landlord-invitation-expiry-email-flag-on")
class JointLandlordInvitationExpiryEmailServiceImplFlagOn(
private val invitationRepository: JointLandlordInvitationRepository,
private val expiryEmailNotificationService: EmailNotificationService<JointLandlordInvitationExpiryEmail>,
private val absoluteUrlProvider: AbsoluteUrlProvider,
) : JointLandlordInvitationExpiryEmailService {
override fun sendExpiryEmailsForExpiredInvitations(): List<Long> {
val cutoff = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS)
val expiredInvitations =
invitationRepository
.findAllByInvitationExpiredEmailSentFalseAndCreatedDateBefore(cutoff)
.filter { it.isExpired } // to be safe
val expiredIds = mutableListOf<Long>()

expiredInvitations.forEach { invitation ->
try {
sendExpiryEmailsForInvitation(invitation)
invitation.markAsExpiredEmailSent()
invitationRepository.save(invitation)
expiredIds.add(invitation.id)
} catch (ex: PersistentEmailSendException) {
printFailureMessage(ex, invitation)
} catch (ex: TransientEmailSentException) {
printFailureMessage(ex, invitation)
}
}

return expiredIds
}

private fun sendExpiryEmailsForInvitation(invitation: JointLandlordInvitation) {
val propertyOwnership = invitation.registeredOwnership
val propertyAddress = propertyOwnership.address.toMultiLineAddress()
val propertyRecordUri = absoluteUrlProvider.buildLandlordPropertyDetailsUri(propertyOwnership.id)

getExpiryEmailRecipients(propertyOwnership).forEach { recipient ->
expiryEmailNotificationService.sendEmail(
recipient.email,
JointLandlordInvitationExpiryEmail(
recipientName = recipient.name,
invitedEmail = invitation.invitedEmail,
propertyAddress = propertyAddress,
propertyRecordUri = propertyRecordUri,
expiryDays = JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS,
),
)
}
}

// TODO PDJB-432: include accepted joint landlords once that data model exists.
private fun getExpiryEmailRecipients(propertyOwnership: PropertyOwnership): List<Landlord> = listOf(propertyOwnership.primaryLandlord)

private fun printFailureMessage(
ex: Exception,
invitation: JointLandlordInvitation,
) {
println("Failed to send expiry email for joint landlord invitation with id: ${invitation.id}")
println("Exception message: ${ex.message}")
println("Stack trace: ${ex.stackTraceToString()}")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE joint_landlord_invitation ADD COLUMN invitation_expired_email_sent BOOLEAN NOT NULL DEFAULT FALSE;
21 changes: 21 additions & 0 deletions src/main/resources/emails/JointLandlordInvitationExpiry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Hello ((recipientName)),

((invitedEmail)) was invited to be a joint landlord for:

((propertyAddress))

We sent them an invitation, but did not hear back. As it has been over ((expiryDays)) days, the invitation has now expired.

# What to do next

All joint landlords must be registered on the property.

If this person is a landlord, invite them again. If you can, contact them and ask them to accept the invitation.

[View property record](((propertyRecordUrl)))

Keep this email so you can reference it later.

---

This is an automated email – do not reply.
7 changes: 7 additions & 0 deletions src/main/resources/emails/emailTemplates.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,5 +155,12 @@
"enumName": "JOINT_LANDLORD_INVITATION_EMAIL",
"subject": "You've been invited to join a rental property registration",
"bodyLocation": "/emails/JointLandlordInvitation.md"
},
{
"test_id": "829697a4-7432-4683-bb43-29f73da609d4",
"prod_id": "0bb9ffed-9319-4369-b9e1-2c7d54d11326",
"enumName": "JOINT_LANDLORD_INVITATION_EXPIRY_EMAIL",
"subject": "Your invitation to a joint landlord has expired",
"bodyLocation": "/emails/JointLandlordInvitationExpiry.md"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ class PrsdbTaskApplicationTests {
PropertyOwnershipSearchRepositoryImpl::class.simpleBeanName,
LandlordSearchRepositoryImpl::class.simpleBeanName,
IncompletePropertiesService::class.simpleBeanName,
// when the feature flagged variant is removed the name overrides from JointLandlordInvitationExpiryEmailService can be removed.
// then, this can be replaced by JointLandlordInvitationExpiryEmailService::class.simpleBeanName
"joint-landlord-invitation-expiry-email-flag-off",
"joint-landlord-invitation-expiry-email-flag-on",
).map { it.lowercase() }.toSet()

val beanNames = ApplicationTestHelper.getAvailableBeanNames(context!!)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ class EmailTemplateModelsTests {
JointLandlordInvitationEmail("John Smith", "1 Fake Street, London", URI("invitationUrl")),
"/emails/JointLandlordInvitation.md",
),
EmailTemplateTestData(
JointLandlordInvitationExpiryEmail(
recipientName = "Lois",
invitedEmail = "invited@example.com",
propertyAddress = "1 Fake Street\nLondon\nSW1A 1AA",
propertyRecordUri = URI("propertyRecordUrl"),
expiryDays = 28,
),
"/emails/JointLandlordInvitationExpiry.md",
),
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package uk.gov.communities.prsdb.webapp.scheduledTaskRunners

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.verify
import org.springframework.context.ApplicationContext
import uk.gov.communities.prsdb.webapp.application.JointLandlordInvitationExpiryEmailTaskApplicationRunner
import uk.gov.communities.prsdb.webapp.application.JointLandlordInvitationExpiryEmailTaskApplicationRunner.Companion.SEND_JOINT_LANDLORD_INVITATION_EXPIRY_EMAILS_TASK_METHOD_NAME
import uk.gov.communities.prsdb.webapp.services.JointLandlordInvitationExpiryEmailService

@ExtendWith(MockitoExtension::class)
class JointLandlordInvitationExpiryEmailTaskApplicationRunnerTests {
@Mock
private lateinit var context: ApplicationContext

@Mock
private lateinit var jointLandlordInvitationExpiryEmailService: JointLandlordInvitationExpiryEmailService

@InjectMocks
private lateinit var runner: JointLandlordInvitationExpiryEmailTaskApplicationRunner

@Test
fun `sendJointLandlordInvitationExpiryEmailsTaskLogic calls service to send expiry emails`() {
// Arrange
val method =
JointLandlordInvitationExpiryEmailTaskApplicationRunner::class.java
.getDeclaredMethod(SEND_JOINT_LANDLORD_INVITATION_EXPIRY_EMAILS_TASK_METHOD_NAME)
method.isAccessible = true

// Act
method.invoke(runner)

// Assert
verify(jointLandlordInvitationExpiryEmailService).sendExpiryEmailsForExpiredInvitations()
}
}
Loading
Loading