feat: Automatic invite reminder email (#3354)

* feat: Add user flags concept, for tracking bits on a user

* feat: Example flag usage for user invite resend abuse

* wip

* test

* fix: Set correct flag
This commit is contained in:
Tom Moor
2022-04-12 20:12:33 -07:00
committed by GitHub
parent 5520317ce1
commit 86f1645199
11 changed files with 237 additions and 6 deletions

View File

@@ -62,6 +62,7 @@ export default async function userInviter({
service: null,
isAdmin: invite.role === "admin",
isViewer: invite.role === "viewer",
invitedById: user.id,
flags: {
[UserFlag.InviteSent]: 1,
},

View File

@@ -0,0 +1,70 @@
import * as React from "react";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
name: string;
actorName: string;
actorEmail: string;
teamName: string;
teamUrl: string;
};
/**
* Email sent to an external user when an admin sends them an invite and they
* haven't signed in after a few days.
*/
export default class InviteReminderEmail extends BaseEmail<Props> {
protected subject({ actorName, teamName }: Props) {
return `Reminder: ${actorName} invited you to join ${teamName}s knowledge base`;
}
protected preview() {
return "Outline is a place for your team to build and share knowledge.";
}
protected renderAsText({
teamName,
actorName,
actorEmail,
teamUrl,
}: Props): string {
return `
This is just a quick reminder that ${actorName} (${actorEmail}) invited you to join them in the ${teamName} team on Outline, a place for your team to build and share knowledge.
We only send a reminder once.
If you haven't signed up yet, you can do so here: ${teamUrl}
`;
}
protected render({ teamName, actorName, actorEmail, teamUrl }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Join {teamName} on Outline</Heading>
<p>
This is just a quick reminder that {actorName} ({actorEmail})
invited you to join them in the {teamName} team on Outline, a place
for your team to build and share knowledge.
</p>
<p>If you haven't signed up yet, you can do so here:</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Join now</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

@@ -8,7 +8,7 @@ module.exports = {
});
},
down: async (queryInterface, Sequelize) => {
down: async (queryInterface) => {
return queryInterface.removeColumn("users", "flags");
}
};

View File

@@ -0,0 +1,17 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.addColumn("users", "invitedById", {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "users",
},
});
},
down: async (queryInterface) => {
return queryInterface.removeColumn("users", "invitedById");
}
};

View File

@@ -57,6 +57,31 @@ export enum UserFlag {
},
],
},
withTeam: {
include: [
{
model: Team,
as: "team",
required: true,
},
],
},
withInvitedBy: {
include: [
{
model: User,
as: "invitedBy",
required: true,
},
],
},
invited: {
where: {
lastActiveAt: {
[Op.is]: null,
},
},
},
}))
@Table({ tableName: "users", modelName: "user" })
@Fix
@@ -141,11 +166,18 @@ class User extends ParanoidModel {
// associations
@HasOne(() => User, "suspendedById")
suspendedBy: User;
suspendedBy: User | null;
@ForeignKey(() => User)
@Column(DataType.UUID)
suspendedById: string;
suspendedById: string | null;
@HasOne(() => User, "invitedById")
invitedBy: User | null;
@ForeignKey(() => User)
@Column(DataType.UUID)
invitedById: string | null;
@BelongsTo(() => Team)
team: Team;

View File

@@ -15,7 +15,7 @@ export default abstract class BaseTask<T> {
* @param props Properties to be used by the task
* @returns A promise that resolves once the job is placed on the task queue
*/
public static schedule<T>(props: T) {
public static schedule<T>(props?: T) {
// @ts-expect-error cannot create an instance of an abstract class, we wont
const task = new this();
return taskQueue.add(

View File

@@ -0,0 +1,37 @@
import { subDays } from "date-fns";
import InviteReminderEmail from "@server/emails/templates/InviteReminderEmail";
import { buildInvite } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import InviteReminderTask from "./InviteReminderTask";
beforeEach(() => flushdb());
describe("InviteReminderTask", () => {
it("should not destroy documents not deleted", async () => {
const spy = jest.spyOn(InviteReminderEmail, "schedule");
// too old
await buildInvite({
createdAt: subDays(new Date(), 3.5),
});
// too new
await buildInvite({
createdAt: new Date(),
});
// should send reminder
await buildInvite({
createdAt: subDays(new Date(), 2.5),
});
const task = new InviteReminderTask();
await task.perform();
// running twice to make sure the email is only sent once
await task.perform();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});

View File

@@ -0,0 +1,67 @@
import { subDays } from "date-fns";
import { Op } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import InviteReminderEmail from "@server/emails/templates/InviteReminderEmail";
import { APM } from "@server/logging/tracing";
import { User } from "@server/models";
import { UserFlag } from "@server/models/User";
import BaseTask, { TaskPriority } from "./BaseTask";
type Props = undefined;
@APM.trace()
export default class InviteReminderTask extends BaseTask<Props> {
public async perform() {
const users = await User.scope("invited").findAll({
attributes: ["id"],
where: {
createdAt: {
[Op.lt]: subDays(new Date(), 2),
[Op.gt]: subDays(new Date(), 3),
},
},
});
const userIds = users.map((user) => user.id);
for (const userId of userIds) {
await sequelize.transaction(async (transaction) => {
const user = await User.scope("withTeam").findByPk(userId, {
lock: {
level: transaction.LOCK.UPDATE,
of: User,
},
transaction,
});
const invitedBy = user?.invitedById
? await User.findByPk(user?.invitedById, { transaction })
: undefined;
if (
user &&
invitedBy &&
user.getFlag(UserFlag.InviteReminderSent) === 0
) {
await InviteReminderEmail.schedule({
to: user.email,
name: user.name,
actorName: invitedBy.name,
actorEmail: invitedBy.email,
teamName: user.team.name,
teamUrl: user.team.url,
});
user.incrementFlag(UserFlag.InviteReminderSent);
await user.save({ transaction });
}
});
}
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
}

View File

@@ -322,7 +322,7 @@ router.post("users.resendInvite", auth(), async (ctx) => {
});
authorize(actor, "resendInvite", user);
if (user.getFlag(UserFlag.InviteReminderSent) > 2) {
if (user.getFlag(UserFlag.InviteSent) > 2) {
throw ValidationError("This invite has been sent too many times");
}
@@ -335,7 +335,7 @@ router.post("users.resendInvite", auth(), async (ctx) => {
teamUrl: actor.team.url,
});
user.incrementFlag(UserFlag.InviteReminderSent);
user.incrementFlag(UserFlag.InviteSent);
await user.save({ transaction });
if (process.env.NODE_ENV === "development") {

View File

@@ -3,6 +3,7 @@ import { AuthenticationError } from "@server/errors";
import CleanupDeletedDocumentsTask from "@server/queues/tasks/CleanupDeletedDocumentsTask";
import CleanupDeletedTeamsTask from "@server/queues/tasks/CleanupDeletedTeamsTask";
import CleanupExpiredFileOperationsTask from "@server/queues/tasks/CleanupExpiredFileOperationsTask";
import InviteReminderTask from "@server/queues/tasks/InviteReminderTask";
const router = new Router();
@@ -19,6 +20,8 @@ router.post("utils.gc", async (ctx) => {
await CleanupDeletedTeamsTask.schedule({ limit });
await InviteReminderTask.schedule();
ctx.body = {
success: true,
};

View File

@@ -161,12 +161,16 @@ export async function buildInvite(overrides: Partial<User> = {}) {
overrides.teamId = team.id;
}
const actor = await buildUser({ teamId: overrides.teamId });
count++;
return User.create({
email: `user${count}@example.com`,
name: `User ${count}`,
createdAt: new Date("2018-01-01T00:00:00.000Z"),
invitedById: actor.id,
...overrides,
lastActiveAt: null,
});
}