Skip to content

Commit e3d4213

Browse files
authored
feat: Invite user to case (#79)
* chore: added actual Roboto font which fell back to sans-serif till now * chore: refactored horizontal spacing out of the UserAvatar component * feat: stage 1 of UI and styling of the user invitation dialog * feat: fetching the unit data and associated users; rendering the roles * chore: added wiring for triggering user invitation from the client * feat: implemented change publication for REST resources * feat: Meteor method for adding a user to a case's CC field * feat: UI shows already invited, and captures interaction to invite more * feat: disabled invitees sorting; list design adjustments * feat: stage 2 of UI functionality for user invitation * chore: made the unit + users loading work with the preloader too * chore: added custom css for Roboto font to prevent faulty fallback * feat: unregistered user invitation * chore: added new env var for the new endpoint access * feat: type enum added to invitations * fix: made names show up instead of email addresses on invitee list #88 * chore: invite dialog layout spacing adjustments * chore: package-lock to see if linting fails on CI due to wrong version
1 parent af09199 commit e3d4213

31 files changed

+5609
-50
lines changed

.env.sample

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
BUGZILLA_ADMIN_KEY=<generate and copy from BZ>
22
MAIL_URL=<SMPT provider URL>
33
CLOUDINARY_URL=<retrieve by registering for a free account on Cloudinary (should end with /image/upload)>
4-
CLOUDINARY_PRESET=<generate in your Cloudinary account (look into 'Unsigned Uploading')>
4+
CLOUDINARY_PRESET=<generate in your Cloudinary account (look into 'Unsigned Uploading')>
5+
API_ACCESS_TOKEN=<set any value that will be used to authenticate API requests to this server by the "accessToken" query param>

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/.idea
22
/node_modules/
3-
/package-lock.json
43
/.vscode/
54
.env
65
/settings.json

AWS-docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ services:
1313
- CLOUDINARY_URL=${CLOUDINARY_URL}
1414
- CLOUDINARY_PRESET=${CLOUDINARY_PRESET}
1515
- COMMIT=${COMMIT}
16+
- API_ACCESS_TOKEN=${API_ACCESS_TOKEN}
1617
logging:
1718
driver: awslogs
1819
options:

aws-env.dev

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export MONGO_PASSWORD=$(aws --profile uneet-dev ssm get-parameters --names MONGO
55
export MONGO_CONNECT="dev-shard-00-00-qxxao.mongodb.net:27017,dev-shard-00-01-qxxao.mongodb.net:27017,dev-shard-00-02-qxxao.mongodb.net:27017/test?ssl=true&replicaSet=Dev-shard-0&authSource=admin"
66
export CLOUDINARY_PRESET=$(aws --profile uneet-dev ssm get-parameters --names CLOUDINARY_PRESET --with-decryption --query Parameters[0].Value --output text)
77
export CLOUDINARY_URL=$(aws --profile uneet-dev ssm get-parameters --names CLOUDINARY_URL --with-decryption --query Parameters[0].Value --output text)
8+
export API_ACCESS_TOKEN=$(aws --profile uneet-dev ssm get-parameters --names API_ACCESS_TOKEN --with-decryption --query Parameters[0].Value --output text)

aws-env.prod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export MONGO_PASSWORD=$(aws --profile uneet-prod ssm get-parameters --names MONG
55
export MONGO_CONNECT="prod-shard-00-00-n2ose.mongodb.net:27017,prod-shard-00-01-n2ose.mongodb.net:27017,prod-shard-00-02-n2ose.mongodb.net:27017/test?ssl=true&replicaSet=Prod-shard-0&authSource=admin"
66
export CLOUDINARY_PRESET=$(aws --profile uneet-prod ssm get-parameters --names CLOUDINARY_PRESET --with-decryption --query Parameters[0].Value --output text)
77
export CLOUDINARY_URL=$(aws --profile uneet-prod ssm get-parameters --names CLOUDINARY_URL --with-decryption --query Parameters[0].Value --output text)
8+
export API_ACCESS_TOKEN=$(aws --profile uneet-prod ssm get-parameters --names API_ACCESS_TOKEN --with-decryption --query Parameters[0].Value --output text)

client/main.css

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
--rad-green: #c9fb65;
77
--very-light-gray: #f0f0f0;
88
--bondi-blue: #0095b6;
9+
--warn-crimson: #ab0530;
10+
--success-green: #99cc33
911
}
1012

1113
/* Tachyons fixes, overrides and additions */
@@ -40,6 +42,13 @@
4042
.moon-gray {
4143
color: var(--moon-gray);
4244
}
45+
.warn-crimson {
46+
color: var(--warn-crimson);
47+
}
48+
49+
.success-green {
50+
color: var(--success-green);
51+
}
4352

4453
.f7 {
4554
font-size: .75rem;
@@ -69,6 +78,10 @@
6978
background-color: var(--rad-green);
7079
}
7180

81+
.bg-bondi-blue {
82+
background-color: var(--bondi-blue);
83+
}
84+
7285
.outline-0 {
7386
outline: 0;
7487
}
@@ -103,6 +116,22 @@
103116
.h2-5 { height: 3rem; }
104117
/* Tachyons Height Scale - end */
105118

119+
/* Tachyons Opacity scale - start */
120+
.o-100 { opacity: 1; }
121+
.o-90 { opacity: .9; }
122+
.o-80 { opacity: .8; }
123+
.o-70 { opacity: .7; }
124+
.o-60 { opacity: .6; }
125+
.o-50 { opacity: .5; }
126+
.o-40 { opacity: .4; }
127+
.o-30 { opacity: .3; }
128+
.o-20 { opacity: .2; }
129+
.o-10 { opacity: .1; }
130+
.o-05 { opacity: .05; }
131+
.o-025 { opacity: .025; }
132+
.o-0 { opacity: 0; }
133+
/* Tachyons Opacity scale - end */
134+
106135
.mw-60 {
107136
max-width: 60%;
108137
}
@@ -125,6 +154,10 @@
125154
flex: 1;
126155
}
127156

157+
.no-shrink {
158+
flex-shrink: 0;
159+
}
160+
128161
.flex-column {
129162
flex-direction: column;
130163
}
@@ -136,6 +169,9 @@
136169
.justify-center {
137170
justify-content: center;
138171
}
172+
.justify-space {
173+
justify-content: space-between;
174+
}
139175

140176
/* Functional CSS additions */
141177
.ellipsis {
@@ -171,3 +207,31 @@
171207
.gap1 {
172208
grid-gap: 0.25rem;
173209
}
210+
211+
.disabled {
212+
pointer-events: none;
213+
}
214+
215+
/* Custom text-indent scale -start */
216+
.ti1 {
217+
text-indent: 0.25rem;
218+
}
219+
.ti2 {
220+
text-indent: 0.5rem;
221+
}
222+
.ti3 {
223+
text-indent: 1rem;
224+
}
225+
.ti4 {
226+
text-indent: 2rem;
227+
}
228+
.ti5 {
229+
text-indent: 4rem;
230+
}
231+
.ti6 {
232+
text-indent: 8rem;
233+
}
234+
.ti7 {
235+
text-indent: 16rem;
236+
}
237+
/* Custom text-indent scale -end */

client/main.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1">
44
<meta name="mobile-web-app-capable" content="yes">
55
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
6+
<link href="/roboto-font.css" rel="stylesheet">
67
<link rel="shortcut icon" href="https://unee-t.com/favicon/favicon.ico">
78
<link rel="icon" href="https://unee-t.com/favicon/cropped-favicon-32x32.png" sizes="32x32">
89
<link rel="icon" href="https://unee-t.com/favicon/cropped-favicon-192x192.png" sizes="192x192">

imports/api/base/rest-resource-factory.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,14 @@ export default ({collectionName, dataResolver}) => {
128128
.reduce((flatList, desc) => flatList.concat(desc.handles), [])
129129
.forEach(handle => handle.added(collectionName, item.id.toString(), item))
130130
},
131-
handleChanged (resourceId, item) {
132-
// TODO: complete implementation
131+
handleChanged (resourceId, itemDiff) {
132+
const idStr = resourceId.toString()
133+
const handles = changedHandles[idStr]
134+
if (handles) { // unlikely to be false, but I can imagine some edge cases
135+
handles.forEach(handle => {
136+
handle.changed(collectionName, idStr, itemDiff)
137+
})
138+
}
133139
},
134140
handleRemoved (resourceId, itemId) {
135141
// TODO: complete implementation

imports/api/cases.js

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { Meteor } from 'meteor/meteor'
22
import { Mongo } from 'meteor/mongo'
3+
import { check } from 'meteor/check'
4+
import bugzillaApi from '../util/bugzilla-api'
35

46
import publicationFactory from './base/rest-resource-factory'
7+
import { emailValidator } from '../util/validators'
58

69
const collectionName = 'cases'
710

@@ -13,13 +16,14 @@ export const factoryOptions = {
1316

1417
const MAX_RESULTS = 20
1518

19+
let publicationObj
1620
if (Meteor.isServer) {
17-
const factory = publicationFactory(factoryOptions)
18-
Meteor.publish('case', factory.publishById({
21+
publicationObj = publicationFactory(factoryOptions)
22+
Meteor.publish('case', publicationObj.publishById({
1923
uriTemplate: caseId => `/rest/bug/${caseId}`
2024
}))
2125
// TODO: Add tests for this
22-
Meteor.publish('myCases', factory.publishByCustomQuery({
26+
Meteor.publish('myCases', publicationObj.publishByCustomQuery({
2327
uriTemplate: () => '/rest/bug',
2428
queryBuilder: subHandle => {
2529
if (!subHandle.userId) {
@@ -57,6 +61,54 @@ if (Meteor.isServer) {
5761
}
5862
}))
5963
}
64+
65+
Meteor.methods({
66+
[`${collectionName}.addParticipant`] (email, caseId) {
67+
check(email, String)
68+
check(caseId, Number)
69+
70+
if (!emailValidator(email)) {
71+
throw new Meteor.Error('Email is no valid')
72+
}
73+
74+
// Making sure the user is logged in before inserting a comment
75+
if (!Meteor.userId()) {
76+
throw new Meteor.Error('not-authorized')
77+
}
78+
79+
const { callAPI } = bugzillaApi
80+
const currUser = Meteor.users.findOne({_id: Meteor.userId()})
81+
82+
if (Meteor.isClient) {
83+
Cases.update({id: caseId}, {
84+
$push: {
85+
cc: email
86+
}
87+
})
88+
} else {
89+
const { token } = currUser.bugzillaCreds
90+
const payload = {
91+
token,
92+
cc: {
93+
add: [email]
94+
}
95+
}
96+
97+
try {
98+
callAPI('put', `/rest/bug/${caseId}`, payload, false, true)
99+
100+
const caseData = callAPI('get', `/rest/bug/${caseId}`, {token}, false, true)
101+
const { cc } = caseData.data.bugs[0]
102+
console.log(`${email} was subscribed to case ${caseId}`)
103+
publicationObj.handleChanged(caseId, {cc})
104+
} catch (e) {
105+
console.error(e)
106+
throw new Meteor.Error('API error')
107+
}
108+
}
109+
}
110+
})
111+
60112
let Cases
61113
if (Meteor.isClient) {
62114
Cases = new Mongo.Collection(collectionName)

imports/api/pending-invitations.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Meteor } from 'meteor/meteor'
2+
import { Mongo } from 'meteor/mongo'
3+
import { Accounts } from 'meteor/accounts-base'
4+
import randToken from 'rand-token'
5+
import { callAPI } from '../util/bugzilla-api'
6+
7+
export const collectionName = 'pendingInvitations'
8+
9+
export const TYPE_ASSIGNED = 'type_assigned'
10+
export const TYPE_CC = 'type_cc'
11+
12+
const allowedTypes = [TYPE_ASSIGNED, TYPE_CC]
13+
14+
Meteor.methods({
15+
[`${collectionName}.inviteNewUser`] (email, role, isOccupant, caseId, unitId, type) {
16+
// Making sure the user is logged in before inserting a comment
17+
if (!Meteor.userId()) throw new Meteor.Error('not-authorized')
18+
if (!allowedTypes.includes(type)) {
19+
throw new Meteor.Error('Invalid invitation type')
20+
}
21+
if (Meteor.isServer) {
22+
const currUser = Meteor.users.findOne({_id: Meteor.userId()})
23+
const { token } = currUser.bugzillaCreds
24+
25+
let unitItem
26+
try {
27+
const unitRequest = callAPI('get', `/rest/product/${unitId}`, {token}, false, true)
28+
unitItem = unitRequest.data.products[0]
29+
} catch (e) {
30+
console.error(e)
31+
throw new Meteor.Error('API Error')
32+
}
33+
34+
// Checking only the default_assigned_to field (Should default_qa_contact be added too in the future?)
35+
const isUserAlreadyInvolved = unitItem.components.filter(
36+
({default_assigned_to: assignedTo}) => assignedTo === email
37+
).length > 0
38+
if (isUserAlreadyInvolved) {
39+
throw new Meteor.Error('This email belongs to a user already assigned to a role in this unit')
40+
}
41+
42+
let inviteeUser = Accounts.findUserByEmail(email)
43+
if (inviteeUser) {
44+
const conflictingCaseInvitations = inviteeUser.invitedToCases.filter(({caseId: caseIdB}) => caseIdB === caseId)
45+
if (conflictingCaseInvitations.length) {
46+
throw new Meteor.Error(
47+
'This user has been invited before to this case, please wait until the invitation is finalized'
48+
)
49+
}
50+
const conflictingUnitInvitations = inviteeUser.invitedToCases.filter(
51+
({role: roleB, isOccupant: isOccupantB, unitId: unitIdB}) => (
52+
unitIdB === unitId && (isOccupantB !== isOccupant || roleB !== role)
53+
)
54+
)
55+
if (conflictingUnitInvitations.length) {
56+
throw new Meteor.Error('This user was already invited to another case for this unit, but in a different role')
57+
}
58+
}
59+
60+
if (!inviteeUser) {
61+
// Using Meteor accounts package to create the user with no signup
62+
Accounts.createUser({
63+
email,
64+
profile: {
65+
isLimited: true
66+
}
67+
})
68+
69+
console.log(`new user created for ${email}`)
70+
71+
inviteeUser = Accounts.findUserByEmail(email)
72+
}
73+
74+
// Updating the invitee user with the details of the invitation
75+
Meteor.users.update(inviteeUser._id, {
76+
$push: {
77+
invitedToCases: {
78+
role,
79+
isOccupant,
80+
caseId,
81+
unitId,
82+
type,
83+
accessToken: randToken.generate(24)
84+
}
85+
}
86+
})
87+
88+
console.log('Invitee updated', inviteeUser._id, Meteor.users.findOne(inviteeUser._id).invitedToCases)
89+
90+
const invitationId = PendingInvitations.insert({
91+
invitedBy: currUser.bugzillaCreds.id,
92+
invitee: inviteeUser.bugzillaCreds.id,
93+
role,
94+
isOccupant,
95+
caseId,
96+
unitId,
97+
type
98+
})
99+
100+
console.log('PendingInvitation created', PendingInvitations.findOne(invitationId))
101+
}
102+
}
103+
})
104+
105+
export const PendingInvitations = new Mongo.Collection(collectionName)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { PendingInvitations } from '../pending-invitations'
2+
export default (req, res) => {
3+
if (req.query.accessToken === process.env.API_ACCESS_TOKEN) {
4+
res.send(PendingInvitations.find().fetch())
5+
} else {
6+
res.send(401)
7+
}
8+
}

imports/api/rest/rest-routes.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* global JsonRoutes */
22
import bodyParser from 'body-parser'
3-
import invitationCreateRoute from './invitation-create'
3+
// import invitationCreateRoute from './invitation-create'
4+
import getPendingInvitations from './get-pending-invitations'
45

56
JsonRoutes.Middleware.use(bodyParser())
67
JsonRoutes.Middleware.use((req, res, next) => {
@@ -20,4 +21,5 @@ const createRoute = (method, url, handler) => {
2021
JsonRoutes.add(method, apiBase + url, handler)
2122
}
2223

23-
createRoute('post', '/invitation', invitationCreateRoute)
24+
// createRoute('post', '/invitation', invitationCreateRoute)
25+
createRoute('get', '/pending-invitations', getPendingInvitations)

0 commit comments

Comments
 (0)