-
Notifications
You must be signed in to change notification settings - Fork 1
PDJB-268: Add joint landlord invitation expiry scheduled task and notification email #1392
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6336368
c654ca1
20f911c
9e90963
03bc15d
a39fa17
b98fc8c
65e8c77
94a6d40
6f8c759
1833f5d
1f0a64e
7e2b6d9
1fe4270
d73dd7d
ff6eda2
5f00cc8
81e9a42
b5a0152
e36cabc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") | ||
| 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 |
|---|---|---|
|
|
@@ -39,6 +39,14 @@ class JointLandlordInvitation( | |
| lateinit var invitingLandlord: Landlord | ||
| private set | ||
|
|
||
| @Column(nullable = false) | ||
| var invitationExpiredEmailSent: Boolean = false | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| private set | ||
|
|
||
| fun markAsExpiredEmailSent() { | ||
| invitationExpiredEmailSent = true | ||
| } | ||
|
|
||
| constructor( | ||
| token: UUID, | ||
| email: String, | ||
|
|
||
| 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 |
|---|---|---|
| @@ -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; |
| 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. |
| 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() | ||
| } | ||
| } |
There was a problem hiding this comment.
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