Skip to content
This repository has been archived by the owner on Mar 28, 2024. It is now read-only.

Commit

Permalink
Implement Email Reminders (#95)
Browse files Browse the repository at this point in the history
* implement task reminders

* remove unused import

* change button label

* add a basic email preference to db

* add a basic user preference for notifications

* don't send email to users who disable it
  • Loading branch information
tpjnorton committed Mar 16, 2022
1 parent 94f1327 commit ad0a3fc
Show file tree
Hide file tree
Showing 12 changed files with 111 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "receiveEmail" BOOLEAN DEFAULT true;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ model User {
sessions Session[]
workspaces WorkspaceMember[]
segment UserOnboardingSegment?
receiveEmail Boolean? @default(true)
}

model Invite {
Expand Down
6 changes: 5 additions & 1 deletion src/backend/models/users/update.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UserOnboardingSegment } from '@prisma/client';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsOptional, IsString, IsBoolean } from 'class-validator';

export class UpdateUserDto {
@IsOptional()
Expand All @@ -13,4 +13,8 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
image: string;

@IsOptional()
@IsBoolean()
receiveEmail: boolean;
}
4 changes: 1 addition & 3 deletions src/components/contacts/labels/ContactLabelTable/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import ContactLabelMenu from './ContactLabelMenu';
import { ContactLabelWithContacts } from 'types/common';
import ContactLabelChip from 'components/contacts/ContactLabelChip';
import useAppColors from 'hooks/useAppColors';

const maybePluralize = (count: number, noun: string, suffix = 's') =>
`${noun}${count !== 1 ? suffix : ''}`;
import { maybePluralize } from 'utils/words';

function ContactLabelCell({
value,
Expand Down
25 changes: 22 additions & 3 deletions src/components/users/UserInformation/EditUserInfoForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Stack, Button, Avatar } from '@chakra-ui/react';
import { Stack, Button, Avatar, Switch } from '@chakra-ui/react';
import React from 'react';
import { useForm } from 'react-hook-form';
import { FiEdit, FiSave } from 'react-icons/fi';
Expand All @@ -16,7 +16,7 @@ interface Props {
userData?: User;
}

type EditUserFormData = Pick<User, 'name' | 'image'>;
type EditUserFormData = Pick<User, 'name' | 'image' | 'receiveEmail'>;

const EditUserInfoForm = ({ onSubmit, onCancel, userData }: Props) => {
const {
Expand All @@ -27,7 +27,11 @@ const EditUserInfoForm = ({ onSubmit, onCancel, userData }: Props) => {
setValue,
watch,
} = useForm<EditUserFormData>({
defaultValues: { name: userData?.name as string, image: userData?.image as string },
defaultValues: {
name: userData?.name as string,
image: userData?.image as string,
receiveEmail: userData?.receiveEmail as boolean,
},
});

const currentImage = watch('image');
Expand Down Expand Up @@ -76,6 +80,21 @@ const EditUserInfoForm = ({ onSubmit, onCancel, userData }: Props) => {
</Stack>
),
},
{
label: 'Email Notifications',
content: (
<FormField
register={register}
errors={errors}
showLabel={false}
name="receiveEmail"
CustomComponent={({ onChange, value }) => {
return <Switch colorScheme="purple" isChecked={!!value} onChange={onChange} />;
}}
control={control}
/>
),
},
];

const queryClient = useQueryClient();
Expand Down
4 changes: 4 additions & 0 deletions src/components/users/UserInformation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const UserInformation = ({ user }: Props) => {
></Avatar>
),
},
{
label: 'Email Notifications',
content: <Text fontWeight="semibold">{user?.receiveEmail ? 'On' : 'Off'}</Text>,
},
];

return (
Expand Down
59 changes: 49 additions & 10 deletions src/pages/api/jobs/reminders/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { NotificationType, TaskStatus } from '@prisma/client';
import { createHandler, Post } from '@storyofams/next-api-decorators';
import { differenceInCalendarDays } from 'date-fns';

import { NotificationEmailData } from './../../../../types/notifications/index';

import { sendDynamicEmail } from 'backend/apiUtils/email';
import { daysFromNow } from 'backend/apiUtils/dates';
import { requiresServiceAccount } from 'backend/apiUtils/decorators/auth';
import prisma from 'backend/prisma/client';
import { dayDifferenceToString, taskHeadingByType } from 'utils/tasks';

const notificationTemplateId = 'd-1b8a97cb16f946ef8b1379f2a61a56a4';

@requiresServiceAccount()
class RemindersHandler {
Expand All @@ -24,11 +31,13 @@ class RemindersHandler {
},
include: {
user: true,
workspace: true,
tasksAssigned: {
where: tasksAreOutstanding,
select: {
id: true,
type: true,
name: true,
release: { select: { name: true } },
status: true,
dueDate: true,
Expand All @@ -37,27 +46,57 @@ class RemindersHandler {
},
});

const notificationsToPost = workspaceMembersToNotify
.map(({ id, tasksAssigned }) => {
return tasksAssigned
.filter((item) => item.dueDate ?? new Date() < new Date())
.map((task) => ({
const distilledNotifications = workspaceMembersToNotify.map(
({ tasksAssigned, id, ...rest }) => {
return {
...rest,
notifications: tasksAssigned.map((tasks) => ({
type: NotificationType.TASK_OVERDUE,
workspaceMemberId: id,
taskId: task.id,
taskId: tasks.id,
extraData: {},
}));
})
.flat(1);
})),
};
}
);

const notificationsToPost = distilledNotifications.map((item) => item.notifications).flat(1);

await prisma.notification.createMany({
data: notificationsToPost,
skipDuplicates: true,
});

let emailsSent = 0;
workspaceMembersToNotify.forEach((member) => {
if (!member.user.receiveEmail) return; // skip users who have disable email notifications

member.tasksAssigned.map(async (task) => {
++emailsSent;
const dayDifference = differenceInCalendarDays(new Date(), new Date(task.dueDate));
await sendDynamicEmail<NotificationEmailData>({
to: member.user.email as string,
templateId: notificationTemplateId,
dynamicTemplateData: {
workspaceName: member.workspace.name,
ctaText: 'View Details',
ctaUrl: `${process.env.NEXTAUTH_URL}/tasks/${task.id}`,
manageUrl: `${process.env.NEXTAUTH_URL}/user/settings`,
title: `${taskHeadingByType(
task.name,
task.type,
task.release.name
)} is ${dayDifferenceToString(dayDifference)}.`,
message: `Click the link below to view this task and update its status or due date if needed.`,
},
});
});
});

return {
acknowledged: true,
created: notificationsToPost.length,
notificationsCreated: notificationsToPost.length,
emailsSent,
};
}
}
Expand Down
1 change: 1 addition & 0 deletions src/pages/api/users/[uid]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class UserHandler {
name: body.name,
segment: body.segment,
image: body.image,
receiveEmail: body.receiveEmail,
},
});

Expand Down
9 changes: 9 additions & 0 deletions src/types/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type NotificationEmailData = {
title: string;
subtitle?: string;
message: string;
ctaUrl: string;
ctaText: string;
workspaceName: string;
manageUrl: string;
};
14 changes: 1 addition & 13 deletions src/utils/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,7 @@ import { differenceInCalendarDays } from 'date-fns';
import { NotificationVisualData } from './types';

import { NotificationWithTask } from 'types/common';
import { taskHeadingByType } from 'utils/tasks';

const dayDifferenceToString = (difference: number): string => {
if (difference === 0) {
return 'now overdue';
}

if (difference < 0) {
return `due in ${-difference} days`;
}

return `overdue by ${difference} days`;
};
import { dayDifferenceToString, taskHeadingByType } from 'utils/tasks';

export const notificationToCopyAndLink = (
notification: NotificationWithTask
Expand Down
14 changes: 14 additions & 0 deletions src/utils/tasks/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ReleaseTask, ReleaseTaskType, TaskStatus } from '@prisma/client';
import { addDays, isAfter, startOfDay, startOfToday } from 'date-fns';

import { maybePluralize } from 'utils/words';

export const taskHeadingByType = (
taskName: string | null,
type: ReleaseTaskType,
Expand Down Expand Up @@ -30,3 +32,15 @@ export const isTaskOverdue = (task: ReleaseTask) => {
export const defaultTaskDueDate = () => {
return addDays(startOfToday(), 7);
};

export const dayDifferenceToString = (difference: number): string => {
if (difference === 0) {
return 'now overdue';
}

if (difference < 0) {
return `due in ${-difference} ${maybePluralize(Math.abs(difference), 'day')}`;
}

return `overdue by ${difference} ${maybePluralize(Math.abs(difference), 'day')}`;
};
2 changes: 2 additions & 0 deletions src/utils/words/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const maybePluralize = (count: number, noun: string, suffix = 's') =>
`${noun}${count !== 1 ? suffix : ''}`;

0 comments on commit ad0a3fc

Please sign in to comment.