Skip to content

Commit

Permalink
Merge pull request #218 from bcgov/invite-notify
Browse files Browse the repository at this point in the history
Notify exsting users with invite
  • Loading branch information
TimCsaky authored Jun 24, 2024
2 parents f27cb89 + 2ad312f commit b6d584a
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 24 deletions.
7 changes: 1 addition & 6 deletions app/src/controllers/email.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import config from 'config';
import emailService from '../services/email';

Expand All @@ -11,11 +10,7 @@ const controller = {
* Send email using CHES API
* https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge
*/
send: async (
req: Request<never, never, Email>,
res: Response,
next: NextFunction
) => {
send: async (req: Request<never, never, Email>, res: Response, next: NextFunction) => {
try {
req.body.from = config.get('server.ches.from');
req.body.bodyType = 'html';
Expand Down
7 changes: 3 additions & 4 deletions app/src/validators/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { emailJoi } from './common';
import { validate } from '../middleware/validation';

const schema = {

mergeSchema: {
body: Joi.object().keys({
body: Joi.string().required(),
Expand All @@ -13,13 +12,13 @@ const schema = {
Joi.object().keys({
to: Joi.array().items(emailJoi).required(),
context: Joi.object().keys({
token: Joi.string().required(),
token: Joi.string(),
fullName: Joi.string()
})
})
)
})
},

}
};

export default {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/BulkPermission.vue
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
}
// format results into human-readable descriptions
results.value = toBulkResult(values.notFound, values.action, resultData);
results.value = toBulkResult(values.notFound, values.action, false, resultData);
// refresh store
if (props.resourceType === 'object') {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/BulkPermissionResults.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Column, DataTable, Dialog } from '@/lib/primevue';
import { Button, Column, DataTable, Dialog } from '@/lib/primevue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
// Props
Expand Down
42 changes: 33 additions & 9 deletions frontend/src/components/common/Invite.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { storeToRefs } from 'pinia';
import { useForm, ErrorMessage } from 'vee-validate';
import * as yup from 'yup';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { Button, RadioButton, Checkbox, useToast, TextArea } from '@/lib/primevue';
import { Button, RadioButton, Checkbox, InputSwitch, useToast, TextArea } from '@/lib/primevue';
import TextInput from '@/components/form/TextInput.vue';
import { Spinner } from '@/components/layout';
import { BulkPermissionResults } from '@/components/common';
Expand Down Expand Up @@ -101,7 +101,8 @@ const { values, defineField, handleSubmit } = useForm({
permCodes: props.resourceType === 'object' ? ['READ'] : [],
email: '',
emailType: 'single',
multiEmail: ''
multiEmail: '',
notify: true
}
});
// maps the input models for vee-validate
Expand All @@ -110,6 +111,7 @@ const [permCodes] = defineField('permCodes', {});
const [emailType] = defineField('emailType', {});
const [email] = defineField('email', {});
const [multiEmail] = defineField('multiEmail', {});
const [notify] = defineField('notify', {});
// require READ perm for file invites
const isDisabled = (optionValue: string) => {
Expand All @@ -136,7 +138,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
values.permCodes.forEach((pc: string) => {
permData.push({ userId: users[0].userId, permCode: pc });
});
return { email: email, userId: users[0].userId, permissions: [] };
return { email: email, user: users[0], permissions: [] };
} else {
newUsers.push(email);
return { email: email };
Expand All @@ -150,13 +152,25 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
props.resourceType === 'object'
? await permissionService.objectAddPermissions(resourceId.value, permData)
: await permissionService.bucketAddPermissions(resourceId.value, permData);
// add permissions data to result
permResponse.data.forEach((p: any) => {
const el = resultData.find((r: any) => r.userId === p.userId);
el.permissions.push({
createdAt: p.createdAt,
permCode: p.permCode
});
const el = resultData.find((r: any) => r.user.userId === p.userId);
el.permissions.push({ createdAt: p.createdAt, permCode: p.permCode });
});
// if notifying existing users about this file/folder
if (values.notify) {
const users = resultData.filter((r: any) => r.user).map((r: any) => r.user);
const emailResponse = await inviteService.notifyUsers(
props.resourceType,
props.resource,
getUser.value?.profile,
users
);
// add to results
emailResponse.data.messages.forEach((msg: { msgId: string; to: Array<string> }) => {
resultData.find((r: any) => r.email === msg.to[0]).chesMsgId = msg.msgId;
});
}
}
// generate invites (for emails not already in the system)
Expand All @@ -176,7 +190,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
});
}
// format results into human-readable descriptions
results.value = toBulkResult('invite', 'add', resultData);
results.value = toBulkResult('invite', 'add', values.notify, resultData);
complete.value = true;
resetForm();
} catch (error: any) {
Expand Down Expand Up @@ -311,6 +325,16 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
</div>
</div>

<p class="mb-2">If a person you are inviting is already using BCBox</p>
<div class="flex flex-wrap gap-3 mb-3">
<InputSwitch
v-model="notify"
aria-label="Notify existing BCBox users"
/>
<span v-if="notify">email them a link to the {{ props.resourceType === 'bucket' ? 'folder' : 'file' }}</span>
<span v-else>don't send them a notification</span>
</div>

<div class="my-4 inline-flex">
<Button
class="p-button p-button-primary mr-3"
Expand Down
48 changes: 46 additions & 2 deletions frontend/src/services/inviteService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { appAxios, comsAxios } from './interceptors';
import { invite as inviteEmailTemplate } from '@/utils/emailTemplates';
import { invite as inviteEmailTemplate, notify as notifyEmailTemplate } from '@/utils/emailTemplates';

const PATH = 'permission/invite';

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

/**
* @function notifyUsers
* Semd email to each invitee containing a link to the resource
* ref: https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge *
* @param {string} resourceType eg bucket or object
* @param {COMSObject | Bucket } resource COMS object or bucket
* @param {User | null} currentUser current user creating the invite
* @param {Array<User>} users array of BCBox users
* @returns {Promise<string>} CHES TransactionId
*/
notifyUsers(resourceType: string, resource: any, currentUser: any, users: Array<any>) {
try {
let resourceName: string, subject: string, resourceUrl: string;
// alternate templates depending if resource is a file or a folder
if (resourceType === 'object') {
resourceName = resource.name;
subject = `You have been invited to access ${resourceName} on BCBox`;
resourceUrl = `${window.location.origin}/detail/objects?objectId=${resource.id}`;
} else {
resourceName = resource.bucketName;
subject = `You have been invited to access ${resourceName} on BCBox`;
resourceUrl = `${window.location.origin}/list/objects?bucketId=${resource.bucketId}`;
}
// build html template for email body
const body = notifyEmailTemplate(resourceType, resourceName, resourceUrl, currentUser);
// define email data matching the structure required by CHES api
const emailData: any = {
contexts: users.map((user: any) => {
return {
to: [user.email],
context: {
fullName: user.fullName ? user.fullName : 'BCBox user'
}
};
}),
subject: subject,
body: body
};
return appAxios().post('email', emailData);
} catch (err) {
return Promise.reject(err);
}
},

/**
* @function getInvite
* Use an invite token
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/utils/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,41 @@ export function invite(resourceType: string, resourceName: string, currentUser:

return html;
}

/**
* creates html email body for share notification
* @param {string} resourceType either 'object' or 'bucket'
* @param {string} resourceName the object name or bucket name
* @param {string} resourceUrl the URL to the resource
* @param {User | null} currentUser current user sending the invite
* @returns {string} the template html
*/
export function notify(resourceType: string, resourceName: string, resourceUrl: string, currentUser: any): string {
let html = '';
// eslint-disable-next-line max-len
const currentUserEmail = `<a href="mailto:${currentUser.email}" style="color: #1a5a96 !important">${currentUser.email}</a>`;
// alternate templates depending if resource is a file or a folder
if (resourceType === 'object') {
html += '<html style="color: #495057 !important; max-width: 500px !important;"><br>';
html += '<p style="color: #495057 !important;">{{fullName}},</p>';
html += `<h2 style="color: #495057 !important;">${currentUserEmail} invited you to access a file on BCBox</h2>`;
html += '<p style="color: #495057 !important;">';
html += `Here's a link to access the file that ${currentUserEmail} shared with you:</p>`;
} else if (resourceType === 'bucket') {
html += '<html"><br>';
html += `<h2 style="color: #495057 !important;">${currentUserEmail} invited you to access a folder on BCBox</h2>\n`;
html += '<p style="color: #495057 !important;">';
html += `Here's a link to access the folder that ${currentUserEmail} shared with you:</p>`;
}
html += '<p>';
html += `<strong><a style="font-size: large; color: #1a5a96" href="${resourceUrl}">`;
html += `${resourceName}</a></strong></p><br>`;
html += `<small style="color: #495057 !important;">
If you do not recognize the sender, do not click on the link above.<br>
Only open links that you are expecting from a known sender.
</small><br><br>
<a style="color: #1a5a96" href="${window.location.origin}">Learn more about BCBox</a>
</html>`;

return html;
}
5 changes: 4 additions & 1 deletion frontend/src/utils/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,21 @@ export function toKebabCase(str: string | null) {
* transforms an array of invite/add/remove data into an array of human-readable descriptions
* @param {string} notFound if user not found (eg: 'invite' or 'ignore')
* @param {string} action permission action (eg: 'add' or 'remove')
* @param {boolean} notify whether existing users were notified
* @param {object[]} data results invite/add/remove
* @returns {object[]} an array of human-readable descriptions
*/
export function toBulkResult(
notFound: string,
action: string,
notify: boolean = false,
data: Array<{ email: string; chesMsgId: string; permissions: Array<{ permCode: string }> | undefined }>
) {
const result = data.map((r) => {
let description: string = 'No action taken';
let status: number = 1;
// invites
if (r.chesMsgId && notFound === 'invite') description = 'Invite emailed';
if (r.chesMsgId && notFound === 'invite' && !r.permissions) description = 'Invite emailed';
else if (notFound === 'ignore' && !r.permissions) {
description = 'No invite was emailed';
status = 0;
Expand All @@ -72,6 +74,7 @@ export function toBulkResult(
description = 'Permissions already existed';
status = 0;
}
if (notify) description += '; notification emailed';
}
// removing permissions
else if (action === 'remove' && r.permissions) {
Expand Down

0 comments on commit b6d584a

Please sign in to comment.