Skip to content

PDJB-268: Add joint landlord invitation expiry scheduled task and notification email#1392

Open
Bill-Haigh wants to merge 13 commits into
mainfrom
feat/PDJB-268-jl-invite-expiry-and-email
Open

PDJB-268: Add joint landlord invitation expiry scheduled task and notification email#1392
Bill-Haigh wants to merge 13 commits into
mainfrom
feat/PDJB-268-jl-invite-expiry-and-email

Conversation

@Bill-Haigh
Copy link
Copy Markdown
Contributor

@Bill-Haigh Bill-Haigh commented May 29, 2026

Ticket number

PDJB-268

Goal of change

Expire pending joint landlord invitations after 28 days and notify the inviting landlord by email.

Description of main change(s)

  • Adds a scheduled task that finds joint landlord invitations older than the configured lifetime (28 days), emails the primary landlord about each one, and deletes the invitation
  • Adds a new "joint landlord invitation expired" Notify email template, view model, and markdown body, with the expiry-days value derived from JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS rather than hardcoded
  • Adds a repository query to fetch invitations created before a given cutoff
  • Adds a helper on AbsoluteUrlProvider to build the absolute URL to the landlord-facing property details page (linked from the expiry email)
  • Wraps the expiry service behind the JOINT_LANDLORDS feature flag (no-op when the flag is off)
  • Registers the service impls with @PrsdbTaskService and extends PrsdbTaskApplicationTests so the beans are exercised in task-runner mode

Anything you'd like to highlight to the reviewer?

  • Expiry emails are currently only sent to the primary landlord. Once PDJB-260 lands, accepted joint landlords should also be notified — there's a TODO in the service flagging this.

Checklist

  • Unit tests for new logic (e.g. new service methods) have been added
  • New email templates have been added to /src/main/resources/emails/emailTemplates.json
  • Branch has been rebased onto main and run locally, with everything working as expected (both for your new feature and any related functionality)
  • Test suite has been run in full locally and is passing
  • QA instructions have been added to the ticket (particularly if this is the last PR required to complete the ticket)

override fun run(args: ApplicationArguments?) {
println("Executing expire joint landlord invitations scheduled task")

jointLandlordInvitationExpiryService.expirePendingInvitations()
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.

do we want any additional logging here?

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.

Yeah might be helpful for debugging purposes, maybe something like "{n} invitations deleted"? I don't think we need lots (although we've not had to do much debugging of scheduled tasks yet so may end up wanting more later!)

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.

yeah, I think if I was debugging this task it'd be useful to get some reference to which landlords it expired. we can put the invitation ID in the logs as is it non-PII

Bill-Haigh and others added 10 commits June 2, 2026 11:56
- Switch JointLandlordInvitationExpiryService impls to @PrsdbTaskService so they load in task-runner mode

- Add the two flag-on/flag-off beans to PrsdbTaskApplicationTests expected-bean set

- Add ExpireJointLandlordInvitationsTaskApplicationRunnerTests covering the runner method

- Derive expiry days from JOINT_LANDLORD_INVITATION_LIFETIME_IN_HOURS and pass via a Notify placeholder instead of hardcoding 28 days

- Reword expiry email subject to reflect the inviting landlord is the recipient
this is how we treat it in code / in wording
much more suitable to test the underlying logic than that a single method call makes another method call
since we can't automatically determine the task name
@samyou-softwire samyou-softwire force-pushed the feat/PDJB-268-jl-invite-expiry-and-email branch from 0321eb3 to 6f8c759 Compare June 2, 2026 10:57
@samyou-softwire samyou-softwire marked this pull request as ready for review June 2, 2026 11:17
@JasminConterioSW JasminConterioSW self-assigned this Jun 2, 2026
override fun run(args: ApplicationArguments?) {
println("Executing expire joint landlord invitations scheduled task")

jointLandlordInvitationExpiryService.expirePendingInvitations()
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.

Yeah might be helpful for debugging purposes, maybe something like "{n} invitations deleted"? I don't think we need lots (although we've not had to do much debugging of scheduled tasks yet so may end up wanting more later!)

private set

@Column(nullable = false)
var expired: 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.

I don't think this should be added to the database, I think we should calculate it from the createdDate when we need to check it.

I'm looking at adding

    fun getInvitationHasExpired(invitation: JointLandlordInvitation): Boolean {
        val dateTimeHelper = DateTimeHelper()

        val expiresOnDate =
            DateTimeHelper
                .getDateInUK(invitation.createdDate.toKotlinInstant())
                .plus(DatePeriod(days = JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS))

        return dateTimeHelper.getCurrentDateInUK() > expiresOnDate

to JointLandlordInvitationService in my current ticket, maybe something like that would work here?

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.

mm I see, the reason why I was thinking to do it this way is that it means that the 'your invite is expired' email and the expiration happening occur at the same time. though thinking again the email is more of a formality so agree let's 'expire' immediately and send the email later

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 thinking of having this method as a prop on JointLandlordInvitation itself as it's specific to the invitation model itself

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.

Adding it to JointLandlordService might be more consistent with what we do elsewhere (it's what I'm likely to do here #1409 if you don't beat me to it)

Comment on lines +39 to +50
val expiredInvitations = invitationRepository.findAllByExpiredFalseAndCreatedDateBefore(cutoff)

expiredInvitations.forEach { invitation ->
try {
sendExpiryEmailsForInvitation(invitation)
invitation.markAsExpired()
invitationRepository.save(invitation)
} catch (ex: PersistentEmailSendException) {
printFailureMessage(ex, invitation)
} catch (ex: TransientEmailSentException) {
printFailureMessage(ex, invitation)
}
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 don't think we need to mark invitations as expired in the db, we should generally just check if they were created before the cutoff.

Although... I'm guessing we want to only send them one email to tell them that the invitation has expired? We did something similar with the incomplete properties reminder task - we added a new ReminderEmailSent table to record in the db which incomplete properties we had sent a reminder email about, and checked to make sure we didn't send another.

We might not need this to be as complicated though, maybe add an invitationExpiredEmailSent field to the invitation entity?

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'll rework the flag to work more like this

Copy link
Copy Markdown
Contributor

@JasminConterioSW JasminConterioSW left a comment

Choose a reason for hiding this comment

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

I've not looked at everything, just a few comments as I'm off tomorrow!

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!

}
}

// TODO PDJB-260: include accepted joint landlords once that data model exists.
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.

This is most likely to be PDJB-264 or Alex's PDJB-432 ticket

private set

@Column(nullable = false)
var expired: 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.

Adding it to JointLandlordService might be more consistent with what we do elsewhere (it's what I'm likely to do here #1409 if you don't beat me to it)

private val expiryEmailNotificationService: EmailNotificationService<JointLandlordInvitationExpiryEmail>,
private val absoluteUrlProvider: AbsoluteUrlProvider,
) : JointLandlordInvitationExpiryService {
override fun expirePendingInvitations(): List<Long> {
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.

This one will probably want to be renamed - expiring isn't something we do to an invitation, it just happens when they get old.

It's more like sendExpiredInvitationEmails (or you could include something saying that we record that too)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants