Branch data Line data Source code
1 [ + ]: 1 : /**
2 : 1 : * @module redis-leaderboard
3 : 1 : * @desc The leaderboard-api Redis leaderboard module.
4 : 1 : * @version 1.0.0
5 : 1 : * @author Essam A. El-Sherif
6 : 1 : */
7 : 1 :
8 : 1 : /**
9 : 1 : * @class
10 : 1 : * @static
11 : 1 : * @desc Class to manage leaderboard system using Redis.
12 : 1 : */
13 : 1 : export class LeaderBoard{
14 : 1 :
15 : 1 : /**
16 : 1 : * @method constructor
17 : 1 : * @instance
18 : 1 : * @memberof module:redis-leaderboard.LeaderBoard
19 : 1 : * @param {object} client - Redis client object.
20 : 1 : * @param {string} keyA - Namespace for Redis activities key(s).
21 : 1 : * @param {string} keyT - Namespace for Redis timestamp key(s).
22 : 1 : * @desc Constructs and returns a LeaderBoard object.
23 : 1 : */
24 [ + ]: 1 : constructor(client, keyT, keyA){
25 : 1 : this.client = client;
26 : 1 : this.keyT = keyT;
27 : 1 : this.keyA = keyA;
28 : 1 : }
29 : 1 :
30 : 1 : /**
31 : 1 : * @method
32 : 1 : * @instance
33 : 1 : * @memberof module:redis-leaderboard.LeaderBoard
34 : 1 : * @param {string} activity.
35 : 1 : * @param {string} username.
36 : 1 : * @param {number} score.
37 : 1 : * @return {Promise} A Promise object that will fulfill upon adding the user's activity/score.
38 : 1 : * @desc Add a user activity/score.
39 : 1 : */
40 [ + ]: 1 : addUserScore(activity, username, score){
41 : 5 :
42 : 5 : let timestamp = String(Date.now());
43 : 5 : score = String(score);
44 : 5 :
45 : 5 : return this.client.sendCommand([
46 : 5 : 'HSET',
47 : 5 : `${this.keyT}${activity}:${username}`,
48 : 5 : 'timestamp', timestamp,
49 : 5 : ])
50 [ + ]: 5 : .then(()=> {
51 : 5 : return this.client.sendCommand([
52 : 5 : 'ZADD',
53 : 5 : `${this.keyA}${activity}`,
54 : 5 : score, username
55 : 5 : ]);
56 : 5 : })
57 [ + ]: 5 : .then(() => {
58 : 5 : return {activity, username, score, timestamp};
59 : 5 : });
60 : 5 : }
61 : 1 :
62 : 1 : /**
63 : 1 : * @method
64 : 1 : * @instance
65 : 1 : * @memberof module:redis-leaderboard.LeaderBoard
66 : 1 : * @param {string} username.
67 : 1 : * @param {string} activity - (Optional).
68 : 1 : * @return {Promise} A Promise object that will fulfill upon removing the user's activity/score.
69 : 1 : * @desc Remove a user's activity/score, or remove all user activities if second parameter was not given.
70 : 1 : */
71 [ + ]: 1 : removeUserScore(username, activity){
72 : 2 :
73 : 2 : let result;
74 : 2 :
75 : 2 : return this.client.sendCommand([
76 : 2 : 'KEYS',
77 [ - ]: 2 : `${this.keyT}${activity || '*'}:${username}`,
78 : 2 : ])
79 [ + ]: 2 : .then((res) => {
80 [ + ]: 2 : res = res.filter(str => str.endsWith(username));
81 [ + ]: 2 : result = res.map(key => ({
82 : 1 : activity: key.replace(`${this.keyT}`, '').replace(`:${username}`, ''),
83 : 1 : username
84 : 2 : }));
85 : 2 : if(res.length)
86 [ + ][ + ]: 2 : return Promise.all(res.map(tsKey => this.client.sendCommand(['DEL', tsKey])));
87 : 1 : else return null;
88 : 2 : })
89 [ + ]: 2 : .then((res) => {
90 : 2 : if(res)
91 [ + ][ - ][ + ]: 2 : return this.client.sendCommand(['KEYS', `${this.keyA}${activity || '*'}`]);
92 : 1 : else return null;
93 : 2 : })
94 [ + ]: 2 : .then((res) => {
95 : 2 : if(res)
96 [ + ][ + ]: 2 : return Promise.all(res.map(key => this.client.sendCommand(['ZREM', key, username])));
97 : 1 : else return null;
98 : 2 : })
99 [ + ]: 2 : .then(() => {
100 : 2 : return result;
101 : 2 : });
102 : 2 : }
103 : 1 :
104 : 1 : /**
105 : 1 : * @method
106 : 1 : * @instance
107 : 1 : * @memberof module:redis-leaderboard.LeaderBoard
108 : 1 : * @param {string} activity.
109 : 1 : * @param {string} username.
110 : 1 : * @return {Promise} A Promise object that will fulfill with the user score and rank object.
111 : 1 : * @desc Return a user score and rank object.
112 : 1 : * @throws {Error} Throws an error if the given activity does not exist.
113 : 1 : */
114 [ + ]: 1 : getUserScoreAndRank(activity, username){
115 : 4 : let score, rank, timestamp;
116 : 4 :
117 : 4 : return this.client.sendCommand([
118 : 4 : 'KEYS',
119 : 4 : `${this.keyA}${activity}`,
120 : 4 : ])
121 [ + ]: 4 : .then((res) => {
122 [ + ]: 4 : if(!res.length){
123 : 1 : throw new Error(`no activity '${activity}'`);
124 [ + ]: 1 : }
125 : 3 : return this.client.sendCommand([
126 : 3 : 'ZSCORE',
127 : 3 : `${this.keyA}${activity}`, username
128 : 3 : ]);
129 : 4 : })
130 [ + ]: 4 : .then((res) => {
131 [ + ]: 3 : if(res !== null){
132 : 2 : score = res;
133 : 2 : return this.client.sendCommand([
134 : 2 : 'ZREVRANK',
135 : 2 : `${this.keyA}${activity}`, username
136 : 2 : ]);
137 [ + ]: 2 : }
138 : 1 : else return res;
139 : 4 : })
140 [ + ]: 4 : .then((res) => {
141 [ + ]: 3 : if(res !== null){
142 : 2 : rank = res + 1;
143 : 2 : return this.client.sendCommand([
144 : 2 : 'HGET',
145 : 2 : `${this.keyT}${activity}:${username}`, 'timestamp'
146 : 2 : ]);
147 [ + ]: 2 : }
148 : 1 : else return res;
149 : 4 : })
150 [ + ]: 4 : .then((res) => {
151 [ + ]: 3 : if(res !== null){
152 : 2 : timestamp = res;
153 : 2 : return {activity, username, score, timestamp, rank};
154 [ + ]: 2 : }
155 : 1 : else return {};
156 : 4 : });
157 : 4 : }
158 : 1 :
159 : 1 : /**
160 : 1 : * @method
161 : 1 : * @instance
162 : 1 : * @memberof module:redis-leaderboard.LeaderBoard
163 : 1 : * @param {string} activity
164 : 1 : * @return {Promise} A Promise object that will fulfill with a leaderboard array of objects.
165 : 1 : * @desc Retrieve a leaderboard array of objects for such activity.
166 : 1 : * @throws {Error} Throws an error if the given activity does not exist.
167 : 1 : */
168 [ + ]: 1 : getActivity(activity){
169 : 2 : let actUser = [];
170 : 2 : let tsKeys = null, tsValues = null;
171 : 2 :
172 : 2 : return this.client.sendCommand([
173 : 2 : 'KEYS',
174 : 2 : `${this.keyA}${activity}`,
175 : 2 : ])
176 [ + ]: 2 : .then((res) => {
177 [ + ]: 2 : if(!res.length){
178 : 1 : throw new Error(`no activity '${activity}'`);
179 : 1 : }
180 : 1 : return this.client.sendCommand([
181 : 1 : 'ZREVRANGE',
182 : 1 : `${this.keyA}${activity}`,
183 : 1 : '0', '-1', 'WITHSCORES'
184 : 1 : ]);
185 : 2 : })
186 [ + ]: 2 : .then((res) => {
187 : 1 :
188 [ + ]: 1 : for(let i = 0; i < res.length; i += 2){
189 : 2 : let entry = {};
190 : 2 : entry.activity = activity;
191 : 2 : entry.username = res[i];
192 : 2 : entry.score = res[i+1];
193 : 2 : entry.timestamp = -1;
194 : 2 : entry.rank = i / 2 + 1;
195 : 2 : actUser.push(entry);
196 : 2 : }
197 : 1 :
198 : 1 : return this.client.sendCommand([
199 : 1 : 'KEYS',
200 : 1 : `${this.keyT}${activity}*`,
201 : 1 : ]);
202 : 2 : })
203 [ + ]: 2 : .then((res) => {
204 : 1 : tsKeys = res;
205 [ + ]: 1 : return Promise.all(res.map(key =>
206 : 2 : this.client.sendCommand([
207 : 2 : 'HGET', key, 'timestamp'])
208 : 1 : ));
209 : 2 : })
210 [ + ]: 2 : .then((res) => {
211 : 1 : tsValues = res;
212 : 1 :
213 : 1 : let tsObj = {};
214 [ + ]: 1 : for(let i = 0; i < tsKeys.length; i++){
215 : 2 : tsObj[tsKeys[i].replace(`${this.keyT}`, '')] = tsValues[i];
216 : 2 : }
217 : 1 :
218 [ + ]: 1 : for(let i = 0; i < actUser.length; i++){
219 : 2 : let key = `${actUser[i].activity}:${actUser[i].username}`;
220 : 2 : actUser[i].timestamp = tsObj[key];
221 : 2 : }
222 : 1 :
223 : 1 : return actUser;
224 : 2 : });
225 : 2 : }
226 : 1 :
227 : 1 : /**
228 : 1 : * @method
229 : 1 : * @instance
230 : 1 : * @memberof module:redis-leaderboard.LeaderBoard
231 : 1 : * @return {Promise} A Promise object that will fulfill with a leaderboard array of objects.
232 : 1 : * @desc Retrieve a leaderboard array of objects for all activities with rank.
233 : 1 : */
234 [ + ]: 1 : getActivities(){
235 : 1 : let actUser = [];
236 : 1 : let actKeys = null, actValues = null;
237 : 1 : let tsKeys = null, tsValues = null;
238 : 1 :
239 : 1 : return this.client.sendCommand([
240 : 1 : 'KEYS',
241 : 1 : `${this.keyA}*`,
242 : 1 : ])
243 [ + ]: 1 : .then((res) => {
244 : 1 : actKeys = res;
245 [ + ]: 1 : return Promise.all(res.map(key =>
246 : 2 : this.client.sendCommand([
247 : 2 : 'ZRANGE',
248 : 2 : key,
249 : 2 : '0', '-1', 'WITHSCORES'])
250 : 1 : )
251 : 1 : );
252 : 1 : })
253 [ + ]: 1 : .then((res) => {
254 : 1 : actValues = res;
255 : 1 :
256 [ + ]: 1 : for(let i = 0; i < actValues.length; i++){
257 [ + ]: 2 : for(let j = 0; j < actValues[i].length; j += 2){
258 : 4 : let entry = {};
259 : 4 : entry.activity = actKeys[i].replace(`${this.keyA}`, '');
260 : 4 : entry.username = actValues[i][j];
261 : 4 : entry.score = actValues[i][j+1];
262 : 4 : entry.timestamp = -1;
263 : 4 : entry.rank = -1;
264 : 4 : actUser.push(entry);
265 : 4 : }
266 : 2 : }
267 : 1 :
268 : 1 : return this.client.sendCommand([
269 : 1 : 'KEYS',
270 : 1 : `${this.keyT}*`,
271 : 1 : ]);
272 : 1 : })
273 [ + ]: 1 : .then((res) => {
274 : 1 : tsKeys = res;
275 [ + ]: 1 : return Promise.all(res.map(key =>
276 : 4 : this.client.sendCommand([
277 : 4 : 'HGET', key, 'timestamp'])
278 : 1 : ));
279 : 1 : })
280 [ + ]: 1 : .then((res) => {
281 : 1 : tsValues = res;
282 : 1 :
283 : 1 : let tsObj = {};
284 [ + ]: 1 : for(let i = 0; i < tsKeys.length; i++){
285 : 4 : tsObj[tsKeys[i].replace(`${this.keyT}`, '')] = tsValues[i];
286 : 4 : }
287 : 1 :
288 [ + ]: 1 : for(let i = 0; i < actUser.length; i++){
289 : 4 : let key = `${actUser[i].activity}:${actUser[i].username}`;
290 : 4 : actUser[i].timestamp = tsObj[key];
291 : 4 : }
292 : 1 :
293 [ + ]: 1 : actUser.sort((a, b) => {
294 [ - ]: 3 : if(+a.score < +b.score) return 1;
295 [ - ]: 3 : else if(+a.score > +b.score) return -1;
296 : 0 : else if(a.activity < b.activity) return -1;
297 : 0 : else if(a.activity > b.activity) return 1;
298 : 0 : else if(a.username < b.username) return -1;
299 : 0 : else return 1;
300 : 1 : })
301 [ + ]: 1 : .forEach((e, i, a) => e.rank = i + 1);
302 : 1 :
303 : 1 : return actUser;
304 : 1 : });
305 : 1 : }
306 : 1 :
307 : 1 : /**
308 : 1 : * @method
309 : 1 : * @instance
310 : 1 : * @memberof module:redis-leaderboard.LeaderBoard
311 : 1 : * @param {string} username
312 : 1 : * @return {Promise} A Promise object that will fulfill with a leaderboard array of objects.
313 : 1 : * @desc Retrieve a leaderboard array of objects for all activities of the given username.
314 : 1 : */
315 [ + ]: 1 : getUserActivities(username){
316 : 2 : let actUser = [];
317 : 2 : let actKeys = null, actValues = null;
318 : 2 : let tsKeys = null, tsValues = null;
319 : 2 :
320 : 2 : return this.client.sendCommand([
321 : 2 : 'KEYS',
322 : 2 : `${this.keyT}*:${username}`,
323 : 2 : ])
324 [ + ]: 2 : .then((res) => {
325 : 2 : tsKeys = res;
326 : 2 :
327 [ + ]: 2 : return Promise.all(res.map(key =>
328 : 2 : this.client.sendCommand([
329 : 2 : 'HGET', key, 'timestamp'])
330 : 2 : ));
331 : 2 : })
332 [ + ]: 2 : .then((res) => {
333 : 2 : tsValues = res;
334 : 2 :
335 : 2 : actKeys = tsKeys
336 [ + ]: 2 : .map(key => key.replace(`:${username}`, ''))
337 [ + ]: 2 : .map(key => key.replace(`${this.keyT}`, `${this.keyA}`));
338 : 2 :
339 [ + ]: 2 : return Promise.all(actKeys.map(key =>
340 : 2 : this.client.sendCommand([
341 : 2 : 'ZRANGE',
342 : 2 : key,
343 : 2 : '0', '-1', 'WITHSCORES'])
344 : 2 : )
345 : 2 : );
346 : 2 : })
347 [ + ]: 2 : .then((res) => {
348 : 2 : for(let i = 0; i < res.length; i++){
349 [ + ]: 2 : for(let j = 0; j < res[i].length; j += 2){
350 [ + ]: 4 : if(res[i][j] === username){
351 : 2 : res[i] = [res[i][j], res[i][j+1]];
352 : 2 : break;
353 : 2 : }
354 : 4 : }
355 : 2 : }
356 : 2 : actValues = res;
357 : 2 : for(let i = 0; i < actValues.length; i++){
358 : 2 : let entry = {};
359 : 2 : entry.activity = actKeys[i].replace(`${this.keyA}`, '');
360 : 2 : entry.username = actValues[i][0];
361 : 2 : entry.score = actValues[i][1];
362 : 2 : entry.timestamp = -1;
363 : 2 : entry.rank = -1;
364 : 2 : actUser.push(entry);
365 : 2 : }
366 : 2 :
367 : 2 : let tsObj = {};
368 : 2 : for(let i = 0; i < tsKeys.length; i++){
369 : 2 : tsObj[tsKeys[i].replace(`${this.keyT}`, '')] = tsValues[i];
370 : 2 : }
371 : 2 :
372 : 2 : for(let i = 0; i < actUser.length; i++){
373 : 2 : let key = `${actUser[i].activity}:${actUser[i].username}`;
374 : 2 : actUser[i].timestamp = tsObj[key];
375 : 2 : }
376 : 2 :
377 [ + ]: 2 : actUser.sort((a, b) => {
378 [ - ]: 1 : if(+a.score < +b.score) return 1;
379 : 0 : else if(+a.score > +b.score) return -1;
380 : 0 : else if(a.activity < b.activity) return -1;
381 : 0 : else if(a.activity > b.activity) return 1;
382 : 0 : else return 1;
383 : 2 : })
384 [ + ]: 2 : .forEach((e, i, a) => e.rank = i + 1);
385 : 2 :
386 : 2 : return actUser;
387 : 2 : });
388 : 2 : }
389 : 1 :
390 : 1 : /**
391 : 1 : * @method
392 : 1 : * @instance
393 : 1 : * @memberof module:redis-leaderboard.LeaderBoard
394 : 1 : * @param {string} activity.
395 : 1 : * @param {number} n.
396 : 1 : * @return {Promise} A Promise object that will fulfill with a leaderboard array of objects.
397 : 1 : * @desc Retrieve a leaderboard array of objects for n top users of the given activity.
398 : 1 : */
399 [ + ]: 1 : getActivityTopUsers(activity, n){
400 : 5 :
401 : 5 : let tsKeys = null, tsValues = null;
402 : 5 :
403 : 5 : return this.client.sendCommand([
404 : 5 : 'KEYS',
405 : 5 : `${this.keyT}${activity}*`
406 : 5 : ])
407 [ + ]: 5 : .then((res) => {
408 : 5 : tsKeys = res;
409 [ + ]: 5 : return Promise.all(res.map(key =>
410 : 8 : this.client.sendCommand([
411 : 8 : 'HGET', key, 'timestamp'])
412 : 5 : ));
413 : 5 : })
414 [ + ]: 5 : .then((res) => {
415 : 5 : tsValues = res;
416 : 5 : return this.client.sendCommand([
417 : 5 : 'ZREVRANGE',
418 : 5 : `${this.keyA}${activity}`, String(0), String(n - 1), 'WITHSCORES']);
419 : 5 : })
420 [ + ]: 5 : .then((res) => {
421 : 5 :
422 : 5 : let tsObj = {};
423 [ + ]: 5 : for(let i = 0; i < tsKeys.length; i++){
424 : 8 : tsObj[tsKeys[i].replace(`${this.keyT}`, '').replace(`${activity}:`, '')] = tsValues[i];
425 : 8 : }
426 : 5 :
427 : 5 : const actUser = [];
428 [ + ]: 5 : for(let i = 0, rank = 1 ; i < res.length ; i += 2, rank++){
429 : 7 : actUser.push({ activity, username: res[i], score: `${res[i + 1]}`, timestamp: tsObj[res[i]], rank: rank });
430 : 7 : }
431 : 5 : return actUser;
432 : 5 : });
433 : 5 : }
434 : 1 : }
|