Skip to content

Commit

Permalink
Notify exsting users with invite
Browse files Browse the repository at this point in the history
For the share feature, also notify existing users where result is just a permission update.
  • Loading branch information
TimCsaky committed Jun 24, 2024
1 parent f27cb89 commit 2ad312f
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 2ad312f

Please sign in to comment.