Skip to content

Commit

Permalink
Merge pull request #4824 from FlowFuse/invite-reminder
Browse files Browse the repository at this point in the history
Send invite Reminders
  • Loading branch information
knolleary authored Dec 5, 2024
2 parents 80a6de9 + 530f4a8 commit e13231e
Show file tree
Hide file tree
Showing 10 changed files with 273 additions and 1 deletion.
15 changes: 15 additions & 0 deletions forge/db/migrations/20241129-01-invite-reminder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Add reminderSentAt to Invitations table
*/
const { DataTypes } = require('sequelize')

module.exports = {
up: async (context) => {
await context.addColumn('Invitations', 'reminderSentAt', {
type: DataTypes.DATE,
allowNull: true,
defaultValue: null
})
},
down: async (context) => {}
}
3 changes: 2 additions & 1 deletion forge/db/models/Invitation.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ module.exports = {
sentAt: { type: DataTypes.DATE, allowNull: true },
role: {
type: DataTypes.INTEGER
}
},
reminderSentAt: { type: DataTypes.DATE, allowNull: true }
// invitorId
// inviteeId
},
Expand Down
2 changes: 2 additions & 0 deletions forge/housekeeper/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ module.exports = fp(async function (app, _opts) {
await registerTask(require('./tasks/licenseOverage'))
await registerTask(require('./tasks/telemetryMetrics'))
await registerTask(require('./tasks/teamBroker'))
await registerTask(require('./tasks/expireInvites'))
await registerTask(require('./tasks/inviteReminder'))

app.addHook('onReady', async () => {
let promise = Promise.resolve()
Expand Down
14 changes: 14 additions & 0 deletions forge/housekeeper/tasks/expireInvites.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const { Op } = require('sequelize')

const { randomInt } = require('../utils')

module.exports = {
name: 'expiredInvites',
startup: true,
// Pick a random hour/minute for this task to run at. If the application is
// horizontal scaled, this will avoid two instances running at the same time
schedule: `${randomInt(0, 59)} ${randomInt(0, 23)} * * *`,
run: async function (app) {
await app.db.models.Invitation.destroy({ where: { expiresAt: { [Op.lt]: Date.now() } } })
}
}
73 changes: 73 additions & 0 deletions forge/housekeeper/tasks/inviteReminder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
const { Op } = require('sequelize')

const { randomInt } = require('../utils')

module.exports = {
name: 'inviteReminder',
startup: false,
// runs daily at a randomly picked time
schedule: `${randomInt(0, 59)} 6 * * *`,
run: async function (app) {
// need to iterate over invitations and send email to all over
// 2 days old, but less than 3 days.
const twoDays = new Date()
twoDays.setDate(twoDays.getDate() - 2)
const threeDays = new Date()
threeDays.setDate(threeDays.getDate() - 3)
const invites = await app.db.models.Invitation.findAll({
where: {
createdAt: {
[Op.between]: [threeDays, twoDays]
},
reminderSentAt: {
[Op.is]: null
}
},
include: [
{ model: app.db.models.User, as: 'invitor' },
{ model: app.db.models.User, as: 'invitee' },
{ model: app.db.models.Team, as: 'team' }
]
})

for (const invite of invites) {
const expiryDate = invite.expiresAt.toDateString()
let invitee = ''
if (invite.invitee) {
invitee = invite.invitee.name
// Existing user
await app.postoffice.send(invite.invitee, 'TeamInviteReminder', {
teamName: invite.team.name,
signupLink: `${app.config.base_url}/account/teams/invitations`,
expiryDate
})
} else if (invite.email) {
invitee = invite.email
// External user
let signupLink = `${app.config.base_url}/account/create?email=${encodeURIComponent(invite.email)}`
if (app.license.active()) {
// Check if this is for an SSO-enabled domain with auto-create turned on
const providerConfig = await app.db.models.SAMLProvider.forEmail(invite.email)
if (providerConfig?.options?.provisionNewUsers) {
signupLink = `${app.config.base_url}`
}
}

await app.postoffice.send(invite, 'UnknownUserInvitationReminder', {
invite,
signupLink,
expiryDate
})
}
invite.reminderSentAt = Date.now()
await invite.save()

// send reminder to Invitor
await app.postoffice.send(invite.invitor, 'TeamInviterReminder', {
teamName: invite.team.name,
invitee,
expiryDate
})
}
}
}
3 changes: 3 additions & 0 deletions forge/postoffice/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ module.exports = fp(async function (app, _opts) {
if (templateContext.teamName) {
templateContext.teamName = sanitizeText(templateContext.teamName)
}
if (templateContext.invitee) {
templateContext.invitee = sanitizeText(templateContext.invitee)
}
const mail = {
to: user.email,
subject: template.subject(templateContext, { allowProtoPropertiesByDefault: true, allowProtoMethodsByDefault: true }),
Expand Down
18 changes: 18 additions & 0 deletions forge/postoffice/templates/TeamInviteReminder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module.exports = {
subject: 'Invitation to join team {{{teamName.text}}} on FlowFuse',
text:
`Hello!
This is a reminder that you have an invite to join team {{{teamName.text}}} on the FlowFuse platform.
This invitation will expire on {{{expiryDate}}}.
{{{ signupLink }}}
`,
html:
`<p>Hello!</p>
<p>You've been invited to join team {{{teamName.html}}} on the FlowFuse platform.</p>
<p>This invitation will expire on {{{expiryDate}}}.</p>
<p><a href="{{{ signupLink }}}">Sign Up!</a></p>
`
}
16 changes: 16 additions & 0 deletions forge/postoffice/templates/TeamInviterReminder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module.exports = {
subject: 'Invitation for {{{invitee.text}}} to {{{teamName.text}}} not accepted yet',
text:
`
Hello,
You invited {{{invitee.text}}} to join FlowFuse Team {{{teamName.text}}}, but they have not yet accepted.
This invitation will expire on {{{expiryDate}}}.
`,
html:
`<p>Hello</p>
<p>You invited {{{invitee.html}}} to join FlowFuse Team {{{teamName.html}}}, but they have not yet accepted.</p>
<p>This invitation will expire on {{{expiryDate}}}.</p>
`
}
18 changes: 18 additions & 0 deletions forge/postoffice/templates/UnknownUserInvitationReminder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module.exports = {
subject: 'Invitation to collaborate on FlowFuse',
text:
`Hello!
This is quick reminder that you've been invited to join the FlowFuse platform. Use the link below to sign-up and get started.
This invitation will expire on {{{expiryDate}}}.
{{{ signupLink }}}
`,
html:
`<p>Hello!</p>
<p>This is quick reminder that you've been invited to join the FlowFuse platform. Use the link below to sign-up and get started.</p>
<p>This invitation will expire on {{{expiryDate}}}.</p>
<p><a href="{{{ signupLink }}}">Sign Up!</a></p>
`
}
112 changes: 112 additions & 0 deletions test/unit/forge/routes/api/teamInvitations_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -346,4 +346,116 @@ describe('Team Invitations API', function () {
response.statusCode.should.equal(404)
})
})

describe('Send invite reminders', async function () {
before(function () {
app.settings.set('team:user:invite:external', true)
})
after(function () {
app.settings.set('team:user:invite:external', false)
})
it('Reminder should be sent after 2 days (internal)', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/v1/teams/${TestObjects.BTeam.hashid}/invitations`,
cookies: { sid: TestObjects.tokens.bob },
payload: {
user: 'chris'
}
})
const result = response.json()
result.should.have.property('status', 'okay')
const invites = await app.db.models.Invitation.findAll({
where: {
inviteeId: TestObjects.chris.id
}
})
const origTime = invites[0].createdAt
origTime.setDate(origTime.getDate() - 2)
origTime.setHours(origTime.getHours() - 2)
invites[0].createdAt = origTime
invites[0].changed('createdAt', true)
await invites[0].save()

const houseKeepingJob = require('../../../../../forge/housekeeper/tasks/inviteReminder')
await houseKeepingJob.run(app)
app.config.email.transport.getMessageQueue().should.have.lengthOf(3)
app.config.email.transport.getMessageQueue()[1].to.should.equal(TestObjects.chris.email)
app.config.email.transport.getMessageQueue()[1].subject.should.equal('Invitation to join team BTeam on FlowFuse')
app.config.email.transport.getMessageQueue()[2].to.should.equal(TestObjects.bob.email)
app.config.email.transport.getMessageQueue()[2].subject.should.equal('Invitation for Chris Kenobi to BTeam not accepted yet')
})
it('Reminder should be sent after 2 days (external)', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/v1/teams/${TestObjects.BTeam.hashid}/invitations`,
cookies: { sid: TestObjects.tokens.bob },
payload: {
user: 'evans@example.com'
}
})
const result = response.json()
result.should.have.property('status', 'okay')
const invites = await app.db.models.Invitation.findAll({
where: {
email: 'evans@example.com'
}
})
const origTime = invites[0].createdAt
origTime.setDate(origTime.getDate() - 2)
origTime.setHours(origTime.getHours() - 2)
invites[0].createdAt = origTime
invites[0].changed('createdAt', true)
await invites[0].save()

const houseKeepingJob = require('../../../../../forge/housekeeper/tasks/inviteReminder')
await houseKeepingJob.run(app)
app.config.email.transport.getMessageQueue().should.have.lengthOf(3)
app.config.email.transport.getMessageQueue()[1].to.should.equal('evans@example.com')
app.config.email.transport.getMessageQueue()[1].subject.should.equal('Invitation to collaborate on FlowFuse')
app.config.email.transport.getMessageQueue()[2].to.should.equal(TestObjects.bob.email)
app.config.email.transport.getMessageQueue()[2].subject.should.equal('Invitation for evans@example com to BTeam not accepted yet')
})
})

describe('Delete expired invites', async function () {
before(function () {
app.settings.set('team:user:invite:external', true)
})
after(function () {
app.settings.set('team:user:invite:external', false)
})
it('Delete invites after 7 days', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/v1/teams/${TestObjects.BTeam.hashid}/invitations`,
cookies: { sid: TestObjects.tokens.bob },
payload: {
user: 'evans@example.com'
}
})
const result = response.json()
result.should.have.property('status', 'okay')
const invites = await app.db.models.Invitation.findAll({
where: {
email: 'evans@example.com'
}
})
const origTime = invites[0].expiresAt
origTime.setDate(origTime.getDate() - 8)
invites[0].expiresAt = origTime
invites[0].changed('expiresAt', true)
await invites[0].save()

const houseKeepingJob = require('../../../../../forge/housekeeper/tasks/expireInvites')
await houseKeepingJob.run(app)

const noInvites = await app.db.models.Invitation.findAll({
where: {
email: 'evans@example.com'
}
})
noInvites.should.have.lengthOf(0)
})
})
})

0 comments on commit e13231e

Please sign in to comment.