Skip to content

Commit 08e857f

Browse files
committed
[COLLAB-22]: Error emails for collaborators (#1280)
## TL;DR This PR adds the ability to send error notification emails to collaborators based on their role (editor or viewer). Owners and Editors of Pipes that have been shared can configure whether to send Editor(s) and/or Viewer(s) email notifications when the Pipe encounters an error. ## Other changes * Added a "Save" button to the notifications settings so that the flow config is only updated once the frequency and collaborators to notify have been selected ## How to test? Share a Pipe with Editor(s) and Viewer(s), trigger an error in your Pipe - [ ] Owner should always receive email - [ ] Editor should be cc-ed in email if "Notify editor(s)" is checked - [ ] Viewer should be cc-ed in email if "Notify viewer(s)" is checked Frequency setting should still be respected - [ ] Should receive email for every error if frequency is set to always - [ ] Should only receive email for the first error if frequency is set to once per day
1 parent 0cb8100 commit 08e857f

File tree

11 files changed

+177
-62
lines changed

11 files changed

+177
-62
lines changed

packages/backend/src/graphql/mutations/duplicate-flow.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const duplicateFlow: MutationResolvers['duplicateFlow'] = async (
3737
delete prevConfig['templateConfig']
3838
delete prevConfig['showSurvey']
3939
delete prevConfig['attachments']
40+
delete prevConfig['errorConfig']
4041

4142
const duplicatedFlow = await context.currentUser
4243
.$relatedQuery('flows', trx)

packages/backend/src/graphql/mutations/update-flow-config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type Params = {
1010
input: {
1111
id: string
1212
notificationFrequency: IFlowErrorConfig['notificationFrequency']
13+
notificationRecipients: IFlowErrorConfig['notificationRecipients']
1314
showSurvey: boolean
1415
attachments?: IFlowAttachmentsConfig[]
1516
}
@@ -38,6 +39,13 @@ const updateFlowConfig = async (
3839
}
3940
}
4041

42+
if (params.input.notificationRecipients !== undefined) {
43+
newConfig.errorConfig = {
44+
...newConfig.errorConfig, // If ever undefined (should never be), it gets set to an empty object first
45+
notificationRecipients: params.input.notificationRecipients,
46+
}
47+
}
48+
4149
if (params.input.showSurvey !== undefined) {
4250
newConfig.showSurvey = params.input.showSurvey
4351
}

packages/backend/src/graphql/schema.graphql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ type FlowConfig {
433433

434434
type FlowErrorConfig {
435435
notificationFrequency: NotificationFrequency!
436+
notificationRecipients: [NotificationRecipients]!
436437
}
437438

438439
type FlowTemplateConfig {
@@ -526,6 +527,11 @@ enum NotificationFrequency {
526527
always
527528
}
528529

530+
enum NotificationRecipients {
531+
editor
532+
viewer
533+
}
534+
529535
input FlowAttachmentsConfigInput {
530536
name: String!
531537
displayedValue: String!
@@ -537,6 +543,7 @@ input FlowAttachmentsConfigInput {
537543
input UpdateFlowConfigInput {
538544
id: String!
539545
notificationFrequency: NotificationFrequency
546+
notificationRecipients: [NotificationRecipients]
540547
showSurvey: Boolean
541548
hasLoadedOnce: Boolean
542549
attachments: [FlowAttachmentsConfigInput]

packages/backend/src/helpers/generate-error-email.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { NotificationRecipients } from '@plumber/types'
2+
13
import { DateTime } from 'luxon'
24

35
import appConfig from '@/config/app'
@@ -70,6 +72,25 @@ export async function sendErrorEmail(flow: Flow) {
7072
const userEmail = flow.user.email
7173
const errorKey = `error-alert:${flowId}`
7274
const currDatetime = DateTime.now()
75+
const ccList: string[] = []
76+
77+
// default to notify owner only if no collaborators are specified
78+
const notificationRecipients =
79+
flow.config?.errorConfig?.notificationRecipients ?? []
80+
if (
81+
notificationRecipients.length > 0 &&
82+
flow.collaborators &&
83+
flow.collaborators.length > 0
84+
) {
85+
const collaboratorsToCC = flow.collaborators
86+
.filter((collaborator) =>
87+
notificationRecipients.includes(
88+
collaborator.role as NotificationRecipients,
89+
),
90+
)
91+
.map((collaborator) => collaborator.user.email)
92+
ccList.push(...collaboratorsToCC)
93+
}
7394

7495
const errorDetails = {
7596
flowId,
@@ -83,6 +104,7 @@ export async function sendErrorEmail(flow: Flow) {
83104
body: createBodyErrorMessage(truncatedFlowName, flowId),
84105
recipient: userEmail,
85106
replyTo: '[email protected]',
107+
...(ccList.length > 0 && { cc: ccList }),
86108
})
87109

88110
await redisClient

packages/backend/src/helpers/send-email.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ export interface PostmanEmailRequestBody {
1010
body: string
1111
recipient: string
1212
replyTo?: string
13+
cc?: string[]
1314
}
1415

1516
export async function sendEmail({
1617
subject,
1718
body,
1819
recipient,
1920
replyTo,
21+
cc,
2022
}: PostmanEmailRequestBody): Promise<void> {
2123
try {
2224
await axios.post(
@@ -28,6 +30,7 @@ export async function sendEmail({
2830
from: `Plumber <${appConfig.postman.fromAddress}>`,
2931
...(replyTo && { reply_to: replyTo }),
3032
disable_tracking: true,
33+
...(cc && { cc }),
3134
},
3235
{
3336
headers: {

packages/backend/src/models/flow.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ class Flow extends Base {
6666
notificationFrequency: {
6767
type: 'string',
6868
},
69+
notificationRecipients: {
70+
type: 'array',
71+
items: {
72+
type: 'string',
73+
enum: ['editor', 'viewer'],
74+
},
75+
},
6976
},
7077
},
7178
},

packages/backend/src/workers/helpers/make-action-worker.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,12 @@ export function makeActionWorker(
271271

272272
const flow = await Flow.query()
273273
.findById(job.data.flowId)
274-
.withGraphFetched('user')
274+
.withGraphFetched({
275+
user: true,
276+
collaborators: {
277+
user: true,
278+
},
279+
})
275280
.throwIfNotFound()
276281

277282
const shouldAlwaysSendEmail =

packages/frontend/src/components/EditorSettings/Notifications.tsx

Lines changed: 119 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { useCallback, useContext, useMemo, useState } from 'react'
1+
import { NotificationRecipients } from '@plumber/types'
2+
3+
import { useCallback, useContext } from 'react'
4+
import { Controller, useFormContext } from 'react-hook-form'
25
import { useMutation } from '@apollo/client'
3-
import { Flex, Skeleton, Stack, Text } from '@chakra-ui/react'
4-
import { Menu, useToast } from '@opengovsg/design-system-react'
6+
import { Flex, FormControl, Skeleton, Stack, Text } from '@chakra-ui/react'
7+
import { Button, Checkbox, useToast } from '@opengovsg/design-system-react'
58

9+
import Form from '@/components/Form'
10+
import { SingleSelect } from '@/components/SingleSelect'
611
import { EditorSettingsContext } from '@/contexts/EditorSettings'
712
import { UPDATE_FLOW_CONFIG } from '@/graphql/mutations/update-flow-config'
813

@@ -11,6 +16,11 @@ enum Frequency {
1116
Always = 'always',
1217
}
1318

19+
enum Recipient {
20+
Editor = 'editor',
21+
Viewer = 'viewer',
22+
}
23+
1424
const frequencyOptions = [
1525
{
1626
label: 'First error on this pipe, one email notification per day',
@@ -22,24 +32,110 @@ const frequencyOptions = [
2232
},
2333
]
2434

35+
const recipientOptions = [
36+
{
37+
label: 'editor(s)',
38+
value: 'editor',
39+
},
40+
{
41+
label: 'viewer(s)',
42+
value: 'viewer',
43+
},
44+
]
45+
46+
const DEFAULT_RECIPIENTS: NotificationRecipients[] = []
47+
2548
const DEFAULT_FREQUENCY = Frequency.Once
2649

50+
function NotificationFormFields() {
51+
const { flow } = useContext(EditorSettingsContext)
52+
// NOTE: check is for greater than 1 because collaborators includes the owner
53+
const hasCollaborators =
54+
flow?.collaborators?.length && flow?.collaborators?.length > 1
55+
const isReadOnly = flow?.role === 'viewer'
56+
57+
const {
58+
control,
59+
register,
60+
formState: { isDirty },
61+
} = useFormContext()
62+
63+
return (
64+
<Stack gap={2}>
65+
<Text textStyle="subhead-1">Frequency</Text>
66+
<FormControl key="frequency">
67+
<Skeleton isLoaded={!!flow}>
68+
<Controller
69+
name="frequency"
70+
control={control}
71+
render={({ field: { onChange, value, name } }) => (
72+
<SingleSelect
73+
items={frequencyOptions}
74+
isSearchable={false}
75+
onChange={onChange}
76+
value={value}
77+
name={name}
78+
isClearable={false}
79+
colorScheme="secondary"
80+
isDisabled={isReadOnly}
81+
/>
82+
)}
83+
/>
84+
</Skeleton>
85+
</FormControl>
86+
{hasCollaborators && (
87+
<Stack gap={0}>
88+
<Text textStyle="subhead-1">Collaborators</Text>
89+
{recipientOptions.map((recipient) => (
90+
<FormControl key={recipient.value}>
91+
<Checkbox {...register(recipient.value)} isDisabled={isReadOnly}>
92+
Notify {recipient.label}
93+
</Checkbox>
94+
</FormControl>
95+
))}
96+
</Stack>
97+
)}
98+
<Button
99+
size="sm"
100+
w="fit-content"
101+
type="submit"
102+
isDisabled={!isDirty || isReadOnly}
103+
alignSelf="flex-end"
104+
>
105+
{isDirty ? 'Save' : 'Saved'}
106+
</Button>
107+
</Stack>
108+
)
109+
}
110+
27111
export default function Notifications() {
28112
const { flow } = useContext(EditorSettingsContext)
29113

30114
const frequency =
31115
flow?.config?.errorConfig?.notificationFrequency ?? DEFAULT_FREQUENCY
116+
const notificationRecipients =
117+
flow?.config?.errorConfig?.notificationRecipients ?? DEFAULT_RECIPIENTS
118+
32119
const [updateFlowConfig] = useMutation(UPDATE_FLOW_CONFIG)
33120
const toast = useToast()
34-
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
121+
122+
const defaultValues = {
123+
frequency,
124+
editor: notificationRecipients.includes(Recipient.Editor),
125+
viewer: notificationRecipients.includes(Recipient.Viewer),
126+
}
35127

36128
const onFlowConfigUpdate = useCallback(
37-
async (frequency: Frequency) => {
129+
async (
130+
frequency: Frequency,
131+
notificationRecipients: NotificationRecipients[],
132+
) => {
38133
await updateFlowConfig({
39134
variables: {
40135
input: {
41136
id: flow.id,
42137
notificationFrequency: frequency,
138+
notificationRecipients,
43139
},
44140
},
45141
optimisticResponse: {
@@ -49,18 +145,15 @@ export default function Notifications() {
49145
config: {
50146
errorConfig: {
51147
notificationFrequency: frequency,
148+
notificationRecipients,
52149
},
53150
},
54151
},
55152
},
56153
})
57154

58-
const displayLabel = frequencyOptions
59-
.find((option) => option.value === frequency)
60-
?.label.toLowerCase()
61-
62155
toast({
63-
title: `You will now receive an email notification for ${displayLabel}`,
156+
title: `Notifications settings saved!`,
64157
status: 'success',
65158
duration: 3000,
66159
isClosable: true,
@@ -70,21 +163,6 @@ export default function Notifications() {
70163
[flow.id, updateFlowConfig, toast],
71164
)
72165

73-
const handleClick = useCallback(
74-
(frequencyOption: Frequency) => {
75-
// only update flow config if a different option is selected
76-
if (frequencyOption !== frequency) {
77-
onFlowConfigUpdate(frequencyOption)
78-
}
79-
},
80-
[onFlowConfigUpdate, frequency],
81-
)
82-
83-
const frequencyLabel = useMemo(
84-
() => frequencyOptions.find((option) => option.value === frequency)?.label,
85-
[frequency],
86-
)
87-
88166
return (
89167
<Flex
90168
py={{ base: '2rem', md: '3rem' }}
@@ -103,42 +181,22 @@ export default function Notifications() {
103181
</Text>
104182
</Stack>
105183
<Stack>
106-
<Text textStyle="subhead-1">Frequency</Text>
107-
<Menu isStretch>
108-
<Menu.Button variant="outline" colorScheme="secondary">
109-
<Skeleton isLoaded={!!flow}>
110-
<Text
111-
textStyle="body-1"
112-
color="base.content.default"
113-
whiteSpace="nowrap"
114-
overflow="hidden"
115-
textOverflow="ellipsis"
116-
>
117-
{frequencyLabel}
118-
</Text>
119-
</Skeleton>
120-
</Menu.Button>
121-
<Menu.List>
122-
{frequencyOptions.map((option, index) => (
123-
<Menu.Item
124-
key={index}
125-
onClick={() => handleClick(option.value)}
126-
onMouseEnter={() => setHoveredIndex(index)}
127-
onMouseLeave={() => setHoveredIndex(null)}
128-
sx={{
129-
bg:
130-
hoveredIndex === index ||
131-
(hoveredIndex === null && option.label === frequencyLabel)
132-
? 'interaction.tinted.main.hover'
133-
: 'transparent',
134-
color: 'base.content.default',
135-
}}
136-
>
137-
{option.label}
138-
</Menu.Item>
139-
))}
140-
</Menu.List>
141-
</Menu>
184+
<Form
185+
defaultValues={defaultValues}
186+
onSubmit={(data) => {
187+
const newNotificationRecipients = [
188+
...(data.editor ? [Recipient.Editor] : []),
189+
...(data.viewer ? [Recipient.Viewer] : []),
190+
]
191+
192+
onFlowConfigUpdate(
193+
data.frequency as Frequency,
194+
newNotificationRecipients,
195+
)
196+
}}
197+
>
198+
<NotificationFormFields />
199+
</Form>
142200
</Stack>
143201
</Flex>
144202
)

0 commit comments

Comments
 (0)