Skip to content

Commit 543fc59

Browse files
authored
Merge pull request #167 from hackmcgill/feature/15-userinvitations
15-Account invitation logic
2 parents 026401c + 9f0d66b commit 543fc59

11 files changed

Lines changed: 220 additions & 17 deletions

File tree

assets/email/AccountInvitation.hbs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<b>
2+
Create Your Account:
3+
<a href="{{link}}">here</a>
4+
</b>

constants/general.constant.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,12 @@ EMAIL_SUBJECTS[HACKER_STATUS_CONFIRMED] = `Thanks for confirming your attendance
7373
EMAIL_SUBJECTS[HACKER_STATUS_CANCELLED] = "Sorry to see you go";
7474
EMAIL_SUBJECTS[HACKER_STATUS_CHECKED_IN] = `Welcome to ${HACKATHON_NAME}`;
7575

76-
const CONFIRM_ACC_EMAIL_SUBJECTS = {};
77-
CONFIRM_ACC_EMAIL_SUBJECTS[HACKER] = `Please complete your hacker application for ${HACKATHON_NAME}`;
78-
CONFIRM_ACC_EMAIL_SUBJECTS[SPONSOR] = `You've been invited to create a sponsor account for ${HACKATHON_NAME}`;
79-
CONFIRM_ACC_EMAIL_SUBJECTS[VOLUNTEER] = `You've been invited to create a volunteer account for ${HACKATHON_NAME}`;
80-
CONFIRM_ACC_EMAIL_SUBJECTS[STAFF] = `You've been invited to create a staff account for ${HACKATHON_NAME}`;
76+
const CONFIRM_ACC_EMAIL_SUBJECT = `Please complete your hacker application for ${HACKATHON_NAME}`;
77+
const CREATE_ACC_EMAIL_SUBJECTS = {};
78+
CREATE_ACC_EMAIL_SUBJECTS[HACKER] = `You've been invited to create a hacker account for ${HACKATHON_NAME}`;
79+
CREATE_ACC_EMAIL_SUBJECTS[SPONSOR] = `You've been invited to create a sponsor account for ${HACKATHON_NAME}`;
80+
CREATE_ACC_EMAIL_SUBJECTS[VOLUNTEER] = `You've been invited to create a volunteer account for ${HACKATHON_NAME}`;
81+
CREATE_ACC_EMAIL_SUBJECTS[STAFF] = `You've been invited to create a staff account for ${HACKATHON_NAME}`;
8182

8283
module.exports = {
8384
HACKATHON_NAME: HACKATHON_NAME,
@@ -99,7 +100,8 @@ module.exports = {
99100
EXTENDED_USER_TYPES: EXTENDED_USER_TYPES,
100101
URL_REGEX: URL_REGEX,
101102
EMAIL_SUBJECTS: EMAIL_SUBJECTS,
102-
CONFIRM_ACC_EMAIL_SUBJECTS: CONFIRM_ACC_EMAIL_SUBJECTS,
103+
CREATE_ACC_EMAIL_SUBJECTS: CREATE_ACC_EMAIL_SUBJECTS,
104+
CONFIRM_ACC_EMAIL_SUBJECT: CONFIRM_ACC_EMAIL_SUBJECT,
103105
HACKER: HACKER,
104106
SPONSOR: SPONSOR,
105107
VOLUNTEER: VOLUNTEER,

constants/routes.constant.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ const accountRoutes = {
4848
"patchAnyById": {
4949
requestType: Constants.REQUEST_TYPES.PATCH,
5050
uri: "/api/account/" + Constants.ROLE_CATEGORIES.ALL,
51+
},
52+
"inviteAccount": {
53+
requestType: Constants.REQUEST_TYPES.POST,
54+
uri: "/api/account/invite"
5155
}
5256
};
5357

controllers/account.controller.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ async function updateAccount(req, res) {
105105
}
106106
}
107107

108+
function invitedAccount(req, res){
109+
return res.status(200).json({
110+
message: "Successfully invited user",
111+
data: {}
112+
})
113+
}
114+
115+
108116
module.exports = {
109117
getUserByEmail: Util.asyncMiddleware(getUserByEmail),
110118
getUserById: Util.asyncMiddleware(getUserById),
@@ -113,4 +121,5 @@ module.exports = {
113121
addUser: Util.asyncMiddleware(addUser),
114122

115123
updateAccount: Util.asyncMiddleware(updateAccount),
124+
invitedAccount: invitedAccount
116125
};

middlewares/account.middleware.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ const mongoose = require("mongoose");
55
const Services = {
66
RoleBinding: require("../services/roleBinding.service"),
77
Logger: require("../services/logger.service"),
8-
Account: require("../services/account.service")
8+
Account: require("../services/account.service"),
9+
AccountConfirmation: require("../services/accountConfirmation.service"),
10+
Email: require("../services/email.service")
911
};
1012

1113
const Middleware = {
@@ -113,12 +115,44 @@ async function addAccount(req, res, next) {
113115
next();
114116
}
115117

118+
/**
119+
* @function inviteAccount
120+
* @param {{body: {email: string, accountType: string}}} req
121+
* @param {*} res
122+
* @param {(err?)=>void} next
123+
* @return {void}
124+
* Creates email to provide a link for the user to create an account
125+
*/
126+
async function inviteAccount(req, res, next) {
127+
const email = req.body.email;
128+
const accountType = req.body.accountType;
129+
const confirmationObj = await Services.AccountConfirmation.create(accountType, email);
130+
const confirmationToken = Services.AccountConfirmation.generateToken(confirmationObj.id);
131+
132+
const mailData = Services.AccountConfirmation.generateAccountInvitationEmail(req.hostname, email, accountType, confirmationToken);
133+
if (mailData !== undefined) {
134+
Services.Email.send(mailData, (err) => {
135+
if (err) {
136+
next(err);
137+
} else {
138+
next();
139+
}
140+
});
141+
} else {
142+
return next({
143+
message: Constants.Error.EMAIL_500_MESSAGE,
144+
});
145+
}
146+
147+
}
148+
116149
module.exports = {
117150
parsePatch: parsePatch,
118151
parseAccount: parseAccount,
119152
// untested
120153
addDefaultHackerPermissions: Middleware.Util.asyncMiddleware(addDefaultHackerPermissions),
121154
// untested
122155
updatePassword: Middleware.Util.asyncMiddleware(updatePassword),
123-
addAccount: Middleware.Util.asyncMiddleware(addAccount)
156+
addAccount: Middleware.Util.asyncMiddleware(addAccount),
157+
inviteAccount: Middleware.Util.asyncMiddleware(inviteAccount)
124158
};

middlewares/validators/account.validator.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,9 @@ module.exports = {
2323
VALIDATOR.shirtSizeValidator("body", "shirtSize", true),
2424
VALIDATOR.dateValidator("body", "birthDate", true),
2525
VALIDATOR.phoneNumberValidator("body", "phoneNumber", true)
26+
],
27+
inviteAccountValidator: [
28+
VALIDATOR.emailValidator("body", "email", false),
29+
VALIDATOR.accountTypeValidator("body", "accountType", false)
2630
]
2731
};

middlewares/validators/validator.helper.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,26 @@ function phoneNumberValidator(fieldLocation, fieldname, optional = true) {
552552
}
553553
}
554554

555+
/**
556+
* Validates that field must be a valid account type
557+
* @param {"query" | "body" | "header" | "param"} fieldLocation the location where the field should be found
558+
* @param {string} fieldname name of the field that needs to be validated.
559+
* @param {boolean} optional whether the field is optional or not.
560+
*/
561+
function accountTypeValidator(fieldLocation, fieldname, optional = true) {
562+
const accountType = setProperValidationChainBuilder(fieldLocation, fieldname, "Invalid account type");
563+
if (optional) {
564+
return accountType.optional({
565+
checkFalsy: true
566+
}).isIn(Constants.EXTENDED_USER_TYPES);
567+
}
568+
else{
569+
return accountType.exists()
570+
.withMessage("Account type must be provided")
571+
.isIn(Constants.EXTENDED_USER_TYPES);
572+
}
573+
}
574+
555575
/**
556576
*
557577
* @param {"query" | "body" | "header" | "param"} fieldLocation the location where the field should be found
@@ -601,5 +621,6 @@ module.exports = {
601621
searchValidator: searchValidator,
602622
searchSortValidator: searchSortValidator,
603623
phoneNumberValidator: phoneNumberValidator,
604-
dateValidator: dateValidator
624+
dateValidator: dateValidator,
625+
accountTypeValidator: accountTypeValidator
605626
};

routes/api/account.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,33 @@ module.exports = {
179179
Controllers.Account.getUserById
180180
);
181181

182+
/**
183+
* @api {post} /account/invite invites a user to create an account with the specified accountType
184+
* @apiName inviteAccount
185+
* @apiGroup Account
186+
* @apiVersion 0.0.8
187+
* @apiDescription sends link with token to be used with the account/create route
188+
*
189+
* @apiParam (body) {String} [email] email of the account to be created and where to send the link
190+
* @apiParam (body) {String} [accountType] the type of the account which the user can create, for sponsor this should specify tier as well
191+
*
192+
* @apiSuccess {string} message Success message
193+
* @apiSuccess {object} data Account object
194+
* @apiSuccessExample {object} Success-Response:
195+
* {
196+
"message": "Successfully invited user ",
197+
"data": {}
198+
}
199+
*/
200+
accountRouter.route("/invite").post(
201+
Middleware.Auth.ensureAuthenticated(),
202+
Middleware.Auth.ensureAuthorized(),
203+
Middleware.Validator.Account.inviteAccountValidator,
204+
Middleware.parseBody.middleware,
205+
Middleware.Account.inviteAccount,
206+
Controllers.Account.invitedAccount
207+
);
208+
182209
apiRouter.use("/account", accountRouter);
183210
}
184211
};

services/accountConfirmation.service.js

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async function create(type, email, accountId) {
5757
* @param {ObjectId} accountId
5858
* @returns {string} JWT Token containing accountId and accountConfirmationId
5959
*/
60-
function generateToken(accountConfirmationId, accountId) {
60+
function generateToken(accountConfirmationId, accountId = null) {
6161
const token = jwt.sign({
6262
accountConfirmationId: accountConfirmationId,
6363
accountId: accountId
@@ -68,34 +68,48 @@ function generateToken(accountConfirmationId, accountId) {
6868
}
6969

7070
/**
71-
* Generates the link that the user will use to access the page to finish account creation
71+
* Generates the link that the user will use to access the page to begin account creationg
7272
* @param {'http'|'https'} httpOrHttps
7373
* @param {string} domain the domain of the current
7474
* @param {string} type the model that the
7575
* @param {string} token the reset token
7676
* @returns {string} the string, of form: [http|https]://{domain}/{model}/create?token={token}
7777
*/
78-
function generateTokenLink(httpOrHttps, domain, type, token) {
78+
function generateCreateAccountTokenLink(httpOrHttps, domain, type, token) {
7979
const link = `${httpOrHttps}://${domain}/${type}/create?token=${token}`;
8080
return link;
8181
}
8282

8383
/**
84-
* Generates the mailData for the account confirmation Email.
84+
* Generates the link that the user will use to confirm account and proceed with account creationg
85+
* @param {'http'|'https'} httpOrHttps
86+
* @param {string} domain the domain of the current
87+
* @param {string} type the model that the
88+
* @param {string} token the reset token
89+
* @returns {string} the string, of form: [http|https]://{domain}/{model}/create?token={token}
90+
*/
91+
function generateConfirmTokenLink(httpOrHttps, domain, type, token) {
92+
const link = `${httpOrHttps}://${domain}/${type}/confirm?token=${token}`;
93+
return link;
94+
}
95+
/**
96+
* Generates the mailData for the account confirmation Email. This really only applies to
97+
* hackers as all other accounts are intrinsically confirmed via the email they recieve to invite them
8598
* @param {string} hostname The hostname that this service is running on
8699
* @param {string} receiverEmail The receiver of the email
100+
* @param {string} type the user type
87101
* @param {string} token The account confirmation token
88102
*/
89103
function generateAccountConfirmationEmail(hostname, receiverEmail, type, token) {
90104
const httpOrHttps = (hostname === "localhost") ? "http" : "https";
91105
const address = (hostname === "localhost") ? `localhost:${process.env.PORT}` : hostname;
92-
const tokenLink = generateTokenLink(httpOrHttps, address, type, token);
106+
const tokenLink = generateConfirmTokenLink(httpOrHttps, address, type, token);
93107
var emailSubject = "";
94108
if (token === undefined || tokenLink === undefined) {
95109
return undefined;
96110
}
97111
if (type === Constants.HACKER) {
98-
emailSubject = Constants.CONFIRM_ACC_EMAIL_SUBJECTS[Constants.HACKER];
112+
emailSubject = Constants.CONFIRM_ACC_EMAIL_SUBJECT
99113
}
100114
const handlebarPath = path.join(__dirname, `../assets/email/AccountConfirmation.hbs`);
101115

@@ -109,10 +123,50 @@ function generateAccountConfirmationEmail(hostname, receiverEmail, type, token)
109123
};
110124
return mailData;
111125
}
126+
127+
/*
128+
* Generates the mailData for the account invitation Email.
129+
* @param {string} hostname The hostname that this service is running on
130+
* @param {string} receiverEmail The receiver of the email
131+
* @param {string} type The user type
132+
* @param {string} token The account confirmation token
133+
*/
134+
function generateAccountInvitationEmail(hostname, receiverEmail, type, token) {
135+
const httpOrHttps = (hostname === "localhost") ? "http" : "https";
136+
const address = (hostname === "localhost") ? `localhost:${process.env.PORT}` : hostname;
137+
const tokenLink = generateCreateAccountTokenLink(httpOrHttps, address, type, token);
138+
var emailSubject = "";
139+
if (token === undefined || tokenLink === undefined) {
140+
return undefined;
141+
}
142+
if (type === Constants.HACKER) {
143+
emailSubject = Constants.CREATE_ACC_EMAIL_SUBJECTS[Constants.HACKER];
144+
}
145+
else if(type === Constants.VOLUNTEER){
146+
emailSubject = Constants.CREATE_ACC_EMAIL_SUBJECTS[Constants.VOLUNTEER];
147+
}
148+
else if(Constants.SPONSOR_TIERS.includes(type)){
149+
emailSubject = Constants.CREATE_ACC_EMAIL_SUBJECTS[Constants.SPONSOR];
150+
}
151+
else if(type === Constants.STAFF){
152+
emailSubject = Constants.CREATE_ACC_EMAIL_SUBJECTS[Constants.STAFF];
153+
}
154+
const handlebarPath = path.join(__dirname, `../assets/email/AccountInvitation.hbs`);
155+
const mailData = {
156+
from: process.env.NO_REPLY_EMAIL,
157+
to: receiverEmail,
158+
subject: emailSubject,
159+
html: Services.Email.renderEmail(handlebarPath, {
160+
link: tokenLink
161+
})
162+
};
163+
return mailData;
164+
}
112165
module.exports = {
113166
findById: findById,
114167
findByAccountId: findByAccountId,
115168
create: create,
116169
generateToken: generateToken,
117170
generateAccountConfirmationEmail: generateAccountConfirmationEmail,
171+
generateAccountInvitationEmail: generateAccountInvitationEmail
118172
}

tests/account.test.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const Account = require("../models/account.model");
88
const should = chai.should();
99
const Constants = {
1010
Error: require("../constants/error.constant"),
11+
General: require("../constants/general.constant")
1112
};
1213

1314

@@ -218,7 +219,18 @@ describe("POST confirm account", function () {
218219
res.body.message.should.equal(Constants.Error.ACCOUNT_TOKEN_401_MESSAGE);
219220
done();
220221
})
221-
})
222+
});
223+
it("should FAIL to confirm account that has token with email but no account", function(done) {
224+
chai.request(server.app)
225+
.post('/api/auth/confirm/' + fakeToken)
226+
.type("application/json")
227+
.end(function (err, res) {
228+
res.should.have.status(401);
229+
res.body.should.have.property("message");
230+
res.body.message.should.equal(Constants.Error.ACCOUNT_TOKEN_401_MESSAGE);
231+
done();
232+
})
233+
});
222234
})
223235

224236
describe("PATCH update account", function () {
@@ -430,4 +442,30 @@ describe("GET resend confirmation email", function () {
430442
})
431443
})
432444
})
445+
});
446+
447+
describe("POST invite account", function () {
448+
it("Should succeed to invite a user to create an account", function(done){
449+
util.auth.login(agent, Admin1, (error) => {
450+
if (error) {
451+
agent.close();
452+
return done(error);
453+
}
454+
return agent
455+
.post("/api/account/invite")
456+
.type("application/json")
457+
.send({email: newAccount1.email,
458+
accountType: Constants.General.VOLUNTEER})
459+
// does not have password because of to stripped json
460+
.end(function (err, res) {
461+
if (err) {
462+
return done(err);
463+
}
464+
res.should.have.status(200);
465+
res.body.should.have.property("message");
466+
res.body.message.should.equal("Successfully invited user");
467+
done();
468+
});
469+
});
470+
})
433471
});

0 commit comments

Comments
 (0)