Skip to content
This repository was archived by the owner on Dec 14, 2023. It is now read-only.

Commit da89814

Browse files
authored
Feature/learn upon integration (#204)
* LMS integration * LMS integration * Remove conflict, ensure user persists when changing email
1 parent 02f7b81 commit da89814

File tree

10 files changed

+359
-4
lines changed

10 files changed

+359
-4
lines changed

config/perm/users.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,12 @@ module.exports = function(){
124124
'kpi_number_of_youth_females_registered': [{
125125
role: 'cdf-admin',
126126
}],
127+
'get_lms_link': [{
128+
role: 'basic-user',
129+
userType: 'champion'
130+
}, {
131+
role: 'basic-user',
132+
userType: 'mentor'
133+
}],
127134
};
128135
};

lib/users/lms/award-badge.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use strict';
2+
var async = require('async');
3+
var _ = require('lodash');
4+
5+
/**
6+
* Webhook handler to award badges based on courses
7+
* Courses "Code" must correspond to the badge slug
8+
* @param {Object} certificate contains all the info, header/user/course passed
9+
*/
10+
function awardLMSBadge (args, cb) {
11+
var seneca = this;
12+
var plugin = args.role;
13+
var certif = args;
14+
var user = certif.user;
15+
16+
function checkTestStatus (waterfallCb) {
17+
if (certif.header.webHookType !== 'course_completion')
18+
return cb(null, 'Unhandled webhook');
19+
if (certif.enrollmentStatus !== 'passed' && certif.enrollmentStatus !== 'completed' )
20+
return cb(null, 'Unhandled status');
21+
waterfallCb(null, certif.courseReferenceCode);
22+
}
23+
24+
function getBadge (badgeName, waterfallCb) {
25+
seneca.act({role: 'cd-badges', cmd: 'getBadge', slug: badgeName},
26+
function (err, badge) {
27+
if (err) return cb(err);
28+
waterfallCb(null, badge);
29+
});
30+
}
31+
32+
function getUser (badge, waterfallCb) {
33+
seneca.act({role: 'cd-users', cmd: 'list', query: {lmsId: user.userId}},
34+
function (err, sysUser) {
35+
if (err) return cb(err);
36+
if (_.isEmpty(sysUser)) return cb(null, {ok: false, why: 'LMSUser not found'});
37+
return waterfallCb(null, sysUser[0], badge);
38+
});
39+
}
40+
41+
function awardBadge (sysUser, badge, waterfallCb) {
42+
var applicationData = {
43+
user: sysUser,
44+
badge: badge.badge,
45+
emailSubject: 'You have been awarded a new CoderDojo digital badge!'
46+
};
47+
seneca.act({role: 'cd-badges', cmd: 'sendBadgeApplication',
48+
applicationData: applicationData,
49+
user: {id: null}
50+
},
51+
function (err, user) {
52+
if (err) return cb(err);
53+
waterfallCb();
54+
});
55+
}
56+
57+
58+
async.waterfall([
59+
checkTestStatus,
60+
getBadge,
61+
getUser,
62+
awardBadge
63+
], cb);
64+
65+
}
66+
67+
module.exports = awardLMSBadge;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use strict';
2+
var _ = require('lodash');
3+
var crypto = require('crypto');
4+
5+
/**
6+
* Verify the validity of a LMS certificate by comparing the signature of this cert
7+
* to the value of its hash + our shared private key
8+
* @param {String} certificate the certificate as a string
9+
* @param {String} signature extracted expected hash
10+
*/
11+
function checkValidity (args, cb) {
12+
var seneca = this;
13+
var hash = crypto.createHash('md5');
14+
var webhookSecret = process.env.LMS_WEBHOOK_SECRET;
15+
var signature = args.signature;
16+
var certif = args.certif;
17+
var stringCertif = certif + ':' + webhookSecret;
18+
hash.update(stringCertif);
19+
var digest = hash.digest('hex');
20+
if ( !_.isEmpty(webhookSecret)){
21+
if ( digest !== signature){
22+
cb(null, {ok: false, why: 'Invalid signature', http$:{status: 401}});
23+
} else {
24+
cb(null, {ok: true});
25+
}
26+
} else {
27+
cb(new Error('Missing Webhook Secret'));
28+
}
29+
}
30+
31+
32+
module.exports = checkValidity;

lib/users/lms/get-lms-link.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
'use strict';
2+
var async = require('async');
3+
var _ = require('lodash');
4+
var request = require('request');
5+
var url = require('url');
6+
var crypto = require('crypto');
7+
var Uuid = require('node-uuid');
8+
9+
/**
10+
* Produce an link usable for SQSSO for Learnupon by
11+
* * retrieving or creating the user IF he's allowed to access the LMS
12+
* * synchronize the groups
13+
* to hence provide a link that'll log the user into the LMS,
14+
* with the courses corresponding to its userTypes
15+
* @param {Object} user
16+
* @return {Object} url contains the link to redirect to consume the SQSSO
17+
*/
18+
function getLMSLink (args, cb) {
19+
var seneca = this;
20+
var plugin = args.role;
21+
var APIKey = process.env.LMS_KEY;
22+
var user = args.user;
23+
var APIUrl = 'https://coderdojo.learnupon.com/api/v1/';
24+
var hash = crypto.createHash('md5');
25+
var response = {
26+
'url': ''
27+
};
28+
var LMSUsername = process.env.LMSUsername;
29+
var LMSPassword = process.env.LMSPassword;
30+
/**
31+
* GetLMSUser or Create it if it doesn't exists
32+
* @return {Number} lmsUserId
33+
* @return {Array} UserTypes as GroupMembership
34+
*/
35+
function getUser (waterfallCb) {
36+
var userTypes = [];
37+
var allowedUserTypes = ['mentor', 'champion'];
38+
seneca.act({role: 'cd-profiles', cmd: 'load_user_profile', userId: user.id},
39+
function (err, profile) {
40+
userTypes = [profile.userType];
41+
// Get extended userTypes
42+
seneca.act({role: 'cd-dojos', cmd: 'load_usersdojos', query:{ userId: user.id}},
43+
function (err, userDojos) {
44+
userTypes = _.flatten(userTypes.concat(_.map(userDojos, 'userTypes')));
45+
userTypes = _.intersection(userTypes, allowedUserTypes);
46+
if (!_.isEmpty(userTypes)) {
47+
if (_.isEmpty(user.lmsId)) {
48+
if(!_.isEmpty(args.approval)){
49+
// The user doesn't exists yet on the LMS, we create it to save the corresponding Id
50+
request.post(APIUrl + 'users', {
51+
auth: {
52+
user: LMSUsername,
53+
pass: LMSPassword
54+
},
55+
json: {
56+
User: {
57+
email: user.email,
58+
password: Uuid() }
59+
}
60+
}, function(err, res, lmsUser) {
61+
seneca.act({role: 'cd-users', cmd: 'update', user: {
62+
id: user.id,
63+
lmsId: lmsUser.id
64+
}}, function (err, profile) {
65+
waterfallCb(null, lmsUser.id, userTypes);
66+
});
67+
});
68+
} else {
69+
cb(null, {approvalRequired: true});
70+
}
71+
} else {
72+
// The user already exists in the LMS, no need to recreate it
73+
waterfallCb(null, user.lmsId, userTypes);
74+
}
75+
} else {
76+
cb(null, {ok: false, why: 'UserType not allowed', http$: { status: 403}});
77+
}
78+
});
79+
80+
});
81+
}
82+
83+
/**
84+
* Recover actual membership on LMS
85+
* @param {Number} lmsUserId
86+
* @param {Array} userTypes
87+
* @param {Fn} waterfallCb
88+
*/
89+
function getUserMemberships (lmsUserId, userTypes, waterfallCb) {
90+
// Get the user in LearnUpon
91+
request.get(APIUrl + 'group_memberships?user_id=' + lmsUserId, {
92+
auth: {
93+
user: LMSUsername,
94+
pass: LMSPassword
95+
},
96+
json: true
97+
}, function(err, res, groups) {
98+
var subscribedGroups = _.map(groups.group, 'title');
99+
var userTypesToSync = _.difference(userTypes, subscribedGroups);
100+
waterfallCb(null, lmsUserId, userTypesToSync);
101+
});
102+
}
103+
104+
/**
105+
* Update LMS groups corresponding to the users' ones that arent synced yet
106+
* @param {Number} lmsUserId
107+
* @param {Array} userTypesToSync
108+
* @param {Fn} waterfallCb
109+
*/
110+
function updateGroup (lmsUserId, userTypesToSync, waterfallCb) {
111+
// Get GroupId
112+
async.eachSeries(userTypesToSync, function(userType, serieCb){
113+
request.get(APIUrl+ 'groups?title=' + userType, {
114+
auth: {
115+
user: LMSUsername,
116+
pass: LMSPassword
117+
},
118+
json: true
119+
}, function (err, res, group) {
120+
group = group.groups[0];
121+
// Update group membership
122+
request.post(APIUrl + 'group_memberships', {
123+
auth: {
124+
user: LMSUsername,
125+
pass: LMSPassword
126+
},
127+
json: {
128+
GroupMembership: {
129+
group_id: group.id,
130+
user_id: lmsUserId}
131+
}
132+
}, serieCb);
133+
});
134+
}, function (err, res) {
135+
waterfallCb();
136+
});
137+
}
138+
139+
/**
140+
* Build an URL using the SQSSO login for LearnUpon
141+
* @param {Function} cb waterfall callback
142+
*/
143+
function buildURL (waterfallCb) {
144+
var email = user.email;
145+
var userName = user.name;
146+
var baseUrl = 'https://coderdojo.learnupon.com/sqsso';
147+
var TS = Date.now();
148+
hash.update('USER='+ email +'&TS='+ TS +'&KEY='+ APIKey);
149+
var SSOToken = hash.digest('hex');
150+
response.url = url.format(
151+
_.extend(url.parse(baseUrl),
152+
{
153+
'query': {
154+
'Email': email,
155+
'SSOUserName': email,
156+
'SSOToken': SSOToken,
157+
'TS': TS,
158+
// TODO: use those when we have real FirstName and LastName in the db
159+
// 'FirstName': userName,
160+
// 'LastName': userName
161+
}
162+
}));
163+
waterfallCb();
164+
}
165+
166+
if (!_.isEmpty(LMSUsername) &&
167+
!_.isEmpty(LMSPassword) &&
168+
!_.isEmpty(APIKey)) {
169+
async.waterfall([
170+
getUser,
171+
getUserMemberships,
172+
updateGroup,
173+
buildURL
174+
], function () {
175+
return cb(null, response);
176+
});
177+
} else {
178+
cb(new Error('Missing LMS env keys'));
179+
}
180+
}
181+
182+
module.exports = getLMSLink;

lib/users/lms/update-user.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use strict';
2+
var async = require('async');
3+
var _ = require('lodash');
4+
var request = require('request');
5+
var url = require('url');
6+
var crypto = require('crypto');
7+
var Uuid = require('node-uuid');
8+
9+
/**
10+
* Update LMS user with the new email to ensure that the linking is not lost upon email update
11+
* Doesn't "hard fail" as it shouldn't break the profile saving
12+
* @param {String} userEmail
13+
* @param {String} profileEmail
14+
* @param {String} lmsId
15+
*/
16+
function updateUser (args, cb) {
17+
var seneca = this;
18+
var plugin = args.role;
19+
var APIKey = process.env.LMS_KEY;
20+
var APIUrl = 'https://coderdojo.learnupon.com/api/v1/';
21+
var LMSUsername = process.env.LMSUsername;
22+
var LMSPassword = process.env.LMSPassword;
23+
var oldEmail = args.userEmail;
24+
var newEmail = args.profileEmail;
25+
var lmsId = args.lmsId;
26+
if (!_.isEmpty(lmsId)) {
27+
request.put(APIUrl + 'users/' + lmsId, {
28+
auth: {
29+
user: LMSUsername,
30+
pass: LMSPassword
31+
},
32+
json: {
33+
User: {
34+
email: newEmail,
35+
username: newEmail
36+
}
37+
}
38+
}, function(err, res, lmsUser) {
39+
if (err) {
40+
seneca.log.error(new Error(err));
41+
}
42+
cb();
43+
});
44+
}
45+
}
46+
47+
module.exports = updateUser;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"dependencies": {
2727
"async": "0.9.0",
2828
"cp-i18n-lib": "git+https://github.com/CoderDojo/cp-i18n-lib.git",
29+
"cp-permissions-plugin": "git://github.com/CoderDojo/cp-permissions-plugin#0.0.1",
2930
"cp-logs-lib": "git://github.com/CoderDojo/cp-logs-lib#v1.0.1",
3031
"cp-permissions-plugin": "git://github.com/CoderDojo/cp-permissions-plugin#0.0.1",
3132
"cuid": "1.2.5",

profiles.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ module.exports = function (options) {
173173
if (profile.id) {
174174
profile = _.omit(profile, immutableFields);
175175
}
176+
176177
seneca.make$(ENTITY_NS).save$(profile, function (err, profile) {
177178
if (err) return done(err);
178179
if (process.env.SALESFORCE_ENABLED === 'true') {
@@ -183,7 +184,10 @@ module.exports = function (options) {
183184
}
184185
});
185186
}
186-
187+
// TODO: use seneca-mesh to avoid coupling the integration to the user
188+
if (args.user && !_.isEmpty(profile.email) && args.user.lmsId && args.user.email !== profile.email) {
189+
seneca.act({role: 'cd-users', cmd: 'update_lms_user', lmsId: args.user.lmsId, userEmail: args.user.email, profileEmail: profile.email});
190+
}
187191
syncUserObj(profile, function (err, res) {
188192
if (err) return done(err);
189193

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
DO $$
2+
BEGIN
3+
BEGIN
4+
ALTER TABLE sys_user ADD COLUMN lms_id character varying;
5+
EXCEPTION
6+
WHEN duplicate_column THEN RAISE NOTICE 'column lms_id already exists in sys_user.';
7+
END;
8+
END;
9+
$$

service.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,6 @@ require('./migrate-psql-db.js')(function (err) {
6262

6363
seneca.listen()
6464
.client({ type: 'web', port: 10304, pin: { role: 'cd-salesforce', cmd: '*' } })
65-
.client({ type: 'web', port: 10301, pin: 'role:cd-dojos,cmd:*' });
65+
.client({ type: 'web', port: 10301, pin: 'role:cd-dojos,cmd:*' })
66+
.client({ type: 'web', port: 10305, pin: {role: 'cd-badges', cmd: '*'} });
6667
});

0 commit comments

Comments
 (0)