Skip to content

Commit b6d584a

Browse files
authored
Merge pull request #218 from bcgov/invite-notify
Notify exsting users with invite
2 parents f27cb89 + 2ad312f commit b6d584a

File tree

8 files changed

+127
-24
lines changed

8 files changed

+127
-24
lines changed

app/src/controllers/email.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
import config from 'config';
32
import emailService from '../services/email';
43

@@ -11,11 +10,7 @@ const controller = {
1110
* Send email using CHES API
1211
* https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge
1312
*/
14-
send: async (
15-
req: Request<never, never, Email>,
16-
res: Response,
17-
next: NextFunction
18-
) => {
13+
send: async (req: Request<never, never, Email>, res: Response, next: NextFunction) => {
1914
try {
2015
req.body.from = config.get('server.ches.from');
2116
req.body.bodyType = 'html';

app/src/validators/email.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { emailJoi } from './common';
44
import { validate } from '../middleware/validation';
55

66
const schema = {
7-
87
mergeSchema: {
98
body: Joi.object().keys({
109
body: Joi.string().required(),
@@ -13,13 +12,13 @@ const schema = {
1312
Joi.object().keys({
1413
to: Joi.array().items(emailJoi).required(),
1514
context: Joi.object().keys({
16-
token: Joi.string().required(),
15+
token: Joi.string(),
16+
fullName: Joi.string()
1717
})
1818
})
1919
)
2020
})
21-
},
22-
21+
}
2322
};
2423

2524
export default {

frontend/src/components/common/BulkPermission.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
207207
}
208208
209209
// format results into human-readable descriptions
210-
results.value = toBulkResult(values.notFound, values.action, resultData);
210+
results.value = toBulkResult(values.notFound, values.action, false, resultData);
211211
212212
// refresh store
213213
if (props.resourceType === 'object') {

frontend/src/components/common/BulkPermissionResults.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import { ref } from 'vue';
3-
import { Column, DataTable, Dialog } from '@/lib/primevue';
3+
import { Button, Column, DataTable, Dialog } from '@/lib/primevue';
44
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
55
66
// Props

frontend/src/components/common/Invite.vue

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { storeToRefs } from 'pinia';
44
import { useForm, ErrorMessage } from 'vee-validate';
55
import * as yup from 'yup';
66
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
7-
import { Button, RadioButton, Checkbox, useToast, TextArea } from '@/lib/primevue';
7+
import { Button, RadioButton, Checkbox, InputSwitch, useToast, TextArea } from '@/lib/primevue';
88
import TextInput from '@/components/form/TextInput.vue';
99
import { Spinner } from '@/components/layout';
1010
import { BulkPermissionResults } from '@/components/common';
@@ -101,7 +101,8 @@ const { values, defineField, handleSubmit } = useForm({
101101
permCodes: props.resourceType === 'object' ? ['READ'] : [],
102102
email: '',
103103
emailType: 'single',
104-
multiEmail: ''
104+
multiEmail: '',
105+
notify: true
105106
}
106107
});
107108
// maps the input models for vee-validate
@@ -110,6 +111,7 @@ const [permCodes] = defineField('permCodes', {});
110111
const [emailType] = defineField('emailType', {});
111112
const [email] = defineField('email', {});
112113
const [multiEmail] = defineField('multiEmail', {});
114+
const [notify] = defineField('notify', {});
113115
114116
// require READ perm for file invites
115117
const isDisabled = (optionValue: string) => {
@@ -136,7 +138,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
136138
values.permCodes.forEach((pc: string) => {
137139
permData.push({ userId: users[0].userId, permCode: pc });
138140
});
139-
return { email: email, userId: users[0].userId, permissions: [] };
141+
return { email: email, user: users[0], permissions: [] };
140142
} else {
141143
newUsers.push(email);
142144
return { email: email };
@@ -150,13 +152,25 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
150152
props.resourceType === 'object'
151153
? await permissionService.objectAddPermissions(resourceId.value, permData)
152154
: await permissionService.bucketAddPermissions(resourceId.value, permData);
155+
// add permissions data to result
153156
permResponse.data.forEach((p: any) => {
154-
const el = resultData.find((r: any) => r.userId === p.userId);
155-
el.permissions.push({
156-
createdAt: p.createdAt,
157-
permCode: p.permCode
158-
});
157+
const el = resultData.find((r: any) => r.user.userId === p.userId);
158+
el.permissions.push({ createdAt: p.createdAt, permCode: p.permCode });
159159
});
160+
// if notifying existing users about this file/folder
161+
if (values.notify) {
162+
const users = resultData.filter((r: any) => r.user).map((r: any) => r.user);
163+
const emailResponse = await inviteService.notifyUsers(
164+
props.resourceType,
165+
props.resource,
166+
getUser.value?.profile,
167+
users
168+
);
169+
// add to results
170+
emailResponse.data.messages.forEach((msg: { msgId: string; to: Array<string> }) => {
171+
resultData.find((r: any) => r.email === msg.to[0]).chesMsgId = msg.msgId;
172+
});
173+
}
160174
}
161175
162176
// generate invites (for emails not already in the system)
@@ -176,7 +190,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
176190
});
177191
}
178192
// format results into human-readable descriptions
179-
results.value = toBulkResult('invite', 'add', resultData);
193+
results.value = toBulkResult('invite', 'add', values.notify, resultData);
180194
complete.value = true;
181195
resetForm();
182196
} catch (error: any) {
@@ -311,6 +325,16 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
311325
</div>
312326
</div>
313327

328+
<p class="mb-2">If a person you are inviting is already using BCBox</p>
329+
<div class="flex flex-wrap gap-3 mb-3">
330+
<InputSwitch
331+
v-model="notify"
332+
aria-label="Notify existing BCBox users"
333+
/>
334+
<span v-if="notify">email them a link to the {{ props.resourceType === 'bucket' ? 'folder' : 'file' }}</span>
335+
<span v-else>don't send them a notification</span>
336+
</div>
337+
314338
<div class="my-4 inline-flex">
315339
<Button
316340
class="p-button p-button-primary mr-3"

frontend/src/services/inviteService.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { appAxios, comsAxios } from './interceptors';
2-
import { invite as inviteEmailTemplate } from '@/utils/emailTemplates';
2+
import { invite as inviteEmailTemplate, notify as notifyEmailTemplate } from '@/utils/emailTemplates';
33

44
const PATH = 'permission/invite';
55

@@ -60,7 +60,7 @@ export default {
6060
* @param {string} resourceType eg bucket or object
6161
* @param {COMSObject | Bucket } resource COMS object or bucket
6262
* @param {User | null} currentUser current user creating the invite
63-
* @param {Array<string>} invites array of email adddresses
63+
* @param {Array<{object}>} invites array of email and token pairs
6464
* @returns {Promise<string>} CHES TransactionId
6565
*/
6666
emailInvites(resourceType: string, resource: any, currentUser: any, invites: any) {
@@ -97,6 +97,50 @@ export default {
9797
}
9898
},
9999

100+
/**
101+
* @function notifyUsers
102+
* Semd email to each invitee containing a link to the resource
103+
* ref: https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge *
104+
* @param {string} resourceType eg bucket or object
105+
* @param {COMSObject | Bucket } resource COMS object or bucket
106+
* @param {User | null} currentUser current user creating the invite
107+
* @param {Array<User>} users array of BCBox users
108+
* @returns {Promise<string>} CHES TransactionId
109+
*/
110+
notifyUsers(resourceType: string, resource: any, currentUser: any, users: Array<any>) {
111+
try {
112+
let resourceName: string, subject: string, resourceUrl: string;
113+
// alternate templates depending if resource is a file or a folder
114+
if (resourceType === 'object') {
115+
resourceName = resource.name;
116+
subject = `You have been invited to access ${resourceName} on BCBox`;
117+
resourceUrl = `${window.location.origin}/detail/objects?objectId=${resource.id}`;
118+
} else {
119+
resourceName = resource.bucketName;
120+
subject = `You have been invited to access ${resourceName} on BCBox`;
121+
resourceUrl = `${window.location.origin}/list/objects?bucketId=${resource.bucketId}`;
122+
}
123+
// build html template for email body
124+
const body = notifyEmailTemplate(resourceType, resourceName, resourceUrl, currentUser);
125+
// define email data matching the structure required by CHES api
126+
const emailData: any = {
127+
contexts: users.map((user: any) => {
128+
return {
129+
to: [user.email],
130+
context: {
131+
fullName: user.fullName ? user.fullName : 'BCBox user'
132+
}
133+
};
134+
}),
135+
subject: subject,
136+
body: body
137+
};
138+
return appAxios().post('email', emailData);
139+
} catch (err) {
140+
return Promise.reject(err);
141+
}
142+
},
143+
100144
/**
101145
* @function getInvite
102146
* Use an invite token

frontend/src/utils/emailTemplates.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,41 @@ export function invite(resourceType: string, resourceName: string, currentUser:
3434

3535
return html;
3636
}
37+
38+
/**
39+
* creates html email body for share notification
40+
* @param {string} resourceType either 'object' or 'bucket'
41+
* @param {string} resourceName the object name or bucket name
42+
* @param {string} resourceUrl the URL to the resource
43+
* @param {User | null} currentUser current user sending the invite
44+
* @returns {string} the template html
45+
*/
46+
export function notify(resourceType: string, resourceName: string, resourceUrl: string, currentUser: any): string {
47+
let html = '';
48+
// eslint-disable-next-line max-len
49+
const currentUserEmail = `<a href="mailto:${currentUser.email}" style="color: #1a5a96 !important">${currentUser.email}</a>`;
50+
// alternate templates depending if resource is a file or a folder
51+
if (resourceType === 'object') {
52+
html += '<html style="color: #495057 !important; max-width: 500px !important;"><br>';
53+
html += '<p style="color: #495057 !important;">{{fullName}},</p>';
54+
html += `<h2 style="color: #495057 !important;">${currentUserEmail} invited you to access a file on BCBox</h2>`;
55+
html += '<p style="color: #495057 !important;">';
56+
html += `Here's a link to access the file that ${currentUserEmail} shared with you:</p>`;
57+
} else if (resourceType === 'bucket') {
58+
html += '<html"><br>';
59+
html += `<h2 style="color: #495057 !important;">${currentUserEmail} invited you to access a folder on BCBox</h2>\n`;
60+
html += '<p style="color: #495057 !important;">';
61+
html += `Here's a link to access the folder that ${currentUserEmail} shared with you:</p>`;
62+
}
63+
html += '<p>';
64+
html += `<strong><a style="font-size: large; color: #1a5a96" href="${resourceUrl}">`;
65+
html += `${resourceName}</a></strong></p><br>`;
66+
html += `<small style="color: #495057 !important;">
67+
If you do not recognize the sender, do not click on the link above.<br>
68+
Only open links that you are expecting from a known sender.
69+
</small><br><br>
70+
<a style="color: #1a5a96" href="${window.location.origin}">Learn more about BCBox</a>
71+
</html>`;
72+
73+
return html;
74+
}

frontend/src/utils/formatters.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,19 +44,21 @@ export function toKebabCase(str: string | null) {
4444
* transforms an array of invite/add/remove data into an array of human-readable descriptions
4545
* @param {string} notFound if user not found (eg: 'invite' or 'ignore')
4646
* @param {string} action permission action (eg: 'add' or 'remove')
47+
* @param {boolean} notify whether existing users were notified
4748
* @param {object[]} data results invite/add/remove
4849
* @returns {object[]} an array of human-readable descriptions
4950
*/
5051
export function toBulkResult(
5152
notFound: string,
5253
action: string,
54+
notify: boolean = false,
5355
data: Array<{ email: string; chesMsgId: string; permissions: Array<{ permCode: string }> | undefined }>
5456
) {
5557
const result = data.map((r) => {
5658
let description: string = 'No action taken';
5759
let status: number = 1;
5860
// invites
59-
if (r.chesMsgId && notFound === 'invite') description = 'Invite emailed';
61+
if (r.chesMsgId && notFound === 'invite' && !r.permissions) description = 'Invite emailed';
6062
else if (notFound === 'ignore' && !r.permissions) {
6163
description = 'No invite was emailed';
6264
status = 0;
@@ -72,6 +74,7 @@ export function toBulkResult(
7274
description = 'Permissions already existed';
7375
status = 0;
7476
}
77+
if (notify) description += '; notification emailed';
7578
}
7679
// removing permissions
7780
else if (action === 'remove' && r.permissions) {

0 commit comments

Comments
 (0)