Browse Source

create GET /ideas?filter[trending] (#58)

* create GET /ideas?filter[trending] plus ideas tests little fix

* fix missing coma after rebase

* fix model bugs GET /ideas?filter[trending]

* fix model trending

* add missing semicolons
Agata Andrzejewska 2 years ago
parent
commit
df4d1758d7
No account linked to committer's email

+ 1
- 0
apidoc.raml View File

@@ -374,6 +374,7 @@ types:
374 374
       - ideas with provided creators: `?filter[creators]=username0,username1,username2`
375 375
       - ideas commented by provided users: `?filter[commentedBy]=username0,username1,username2`
376 376
       - highly voted ideas with minimum amount of votes parameter: `?filter[highlyVoted]=bottomValueLimit`
377
+      - trending ideas: `?filter[trending]`
377 378
   /{id}:
378 379
     get:
379 380
       description: Read an idea by id.

+ 2
- 1
controllers/goto/ideas.js View File

@@ -10,6 +10,7 @@ module.exports = {
10 10
     random: route(['query.filter.random']),
11 11
     withCreators: route(['query.filter.creators']),
12 12
     commentedBy: route(['query.filter.commentedBy']),
13
-    highlyVoted: route(['query.filter.highlyVoted'])
13
+    highlyVoted: route(['query.filter.highlyVoted']),
14
+    trending: route(['query.filter.trending'])
14 15
   },
15 16
 };

+ 22
- 1
controllers/ideas.js View File

@@ -254,5 +254,26 @@ async function getIdeasHighlyVoted(req, res, next) {
254 254
   }
255 255
 }
256 256
 
257
+/**
258
+ * Get trending ideas
259
+ */
260
+async function getIdeasTrending(req, res, next) {
261
+  try {
262
+    // gather data
263
+    const { page: { offset = 0, limit = 5 } = { } } = req.query;
264
+
265
+    // read ideas from database
266
+    const foundIdeas = await models.idea.findTrending({ offset, limit });
267
+
268
+    // serialize
269
+    const serializedIdeas = serialize.idea(foundIdeas);
270
+
271
+    // respond
272
+    return res.status(200).json(serializedIdeas);
273
+
274
+  } catch (e) {
275
+    return next(e);
276
+  }
277
+}
257 278
 
258
-module.exports = { get, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patch, post };
279
+module.exports = { get, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patch, post };

+ 1
- 0
controllers/validators/ideas.js View File

@@ -6,6 +6,7 @@ module.exports = {
6 6
   get: validate('getIdea'),
7 7
   getIdeasCommentedBy: validate('getIdeasCommentedBy'),
8 8
   getIdeasHighlyVoted: validate('getIdeasHighlyVoted'),
9
+  getIdeasTrending: validate('getIdeasTrending'),
9 10
   getIdeasWithCreators: validate('getIdeasWithCreators'),
10 11
   getIdeasWithMyTags: validate('getIdeasWithMyTags'),
11 12
   getIdeasWithTags: validate('getIdeasWithTags'),

+ 24
- 1
controllers/validators/schema/ideas.js View File

@@ -195,4 +195,27 @@ const getIdeasHighlyVoted = {
195 195
   required: ['query']
196 196
 };
197 197
 
198
-module.exports = { getIdea, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patchIdea, postIdeas };
198
+const getIdeasTrending = {
199
+  properties: {
200
+    query: {
201
+      properties: {
202
+        filter: {
203
+          properties: {
204
+            trending: {
205
+              type: 'string',
206
+              enum: ['']
207
+            }
208
+          },
209
+          required: ['trending'],
210
+          additionalProperties: false
211
+        },
212
+        page
213
+      },
214
+      required: ['filter'],
215
+      additionalProperties: false
216
+    },
217
+  },
218
+  required: ['query']
219
+};
220
+
221
+module.exports = { getIdea, getIdeasCommentedBy, getIdeasHighlyVoted, getIdeasTrending, getIdeasWithCreators, getIdeasWithMyTags, getIdeasWithTags, getNewIdeas, getRandomIdeas, patchIdea, postIdeas };

+ 49
- 1
models/idea/index.js View File

@@ -277,7 +277,7 @@ class Idea extends Model {
277 277
 
278 278
 
279 279
   /**
280
-   * Read ideas commented by specified users
280
+   * Read highly voted ideas
281 281
    * @param {string[]} voteSumBottomLimit - minimal query voteSum
282 282
    * @param {integer} offset - pagination offset
283 283
    * @param {integer} limit - pagination limit
@@ -305,6 +305,54 @@ class Idea extends Model {
305 305
     const cursor = await this.db.query(query, params);
306 306
     return await cursor.all();
307 307
   }
308
+
309
+
310
+  /**
311
+   * Read trending ideas
312
+   * @param {integer} offset - pagination offset
313
+   * @param {integer} limit - pagination limit
314
+   * @returns {Promise<Idea[]>} - list of found ideas
315
+   */
316
+  static async findTrending({ offset, limit }) {
317
+    const now = Date.now();
318
+    const oneWeek = 604800000; // 1000 * 60 * 60 * 24 * 7
319
+    const threeWeeks = 1814400000; // 1000 * 60 * 60 * 24 * 21
320
+    const threeMonths = 7776000000; // 1000 * 60 * 60 * 24 * 90
321
+    const weekAgo = now - oneWeek;
322
+    const threeWeeksAgo = now - threeWeeks;
323
+    const threeMonthsAgo = now - threeMonths;
324
+
325
+    // for each idea we are counting 'rate'
326
+    // rate is the sum of votes/day in the last three months
327
+    // votes/day from last week are taken with wage 3
328
+    // votes/day from two weeks before last week are taken with wage 2
329
+    // votes/day from the rest of days are taken with wage 1
330
+    const query = `
331
+      FOR idea IN ideas
332
+        FOR vote IN votes
333
+        FILTER idea._id == vote._to
334
+        // group by idea id
335
+        COLLECT id = idea
336
+        // get sum of each idea's votes values from last week, last three weeks and last three months
337
+        AGGREGATE rateWeek = SUM((vote.value * TO_NUMBER( @weekAgo <= vote.created))/7),
338
+                rateThreeWeeks  = SUM((vote.value * TO_NUMBER( @threeWeeksAgo <= vote.created  && vote.created <= @weekAgo))/14),
339
+                rateThreeMonths = SUM((vote.value * TO_NUMBER( @threeMonthsAgo <= vote.created  && vote.created <= @threeWeeksAgo))/69)
340
+        // find creator
341
+        LET c = (DOCUMENT(id.creator))
342
+        LET creator = MERGE(KEEP(c, 'username'), c.profile)
343
+        LET ideaOut = MERGE(KEEP(id, 'title', 'detail', 'created'), { id: id._key}, { creator })
344
+        LET rates = 3*rateWeek + 2*rateThreeWeeks + rateThreeMonths
345
+        FILTER rates > 0
346
+        // sort by sum of rates
347
+        SORT rates DESC
348
+        LIMIT @offset, @limit
349
+        RETURN ideaOut`;
350
+
351
+    const params = { weekAgo, threeWeeksAgo, threeMonthsAgo, offset, limit };
352
+    const cursor = await this.db.query(query, params);
353
+    return await cursor.all();
354
+  }
308 355
 }
309 356
 
357
+
310 358
 module.exports = Idea;

+ 3
- 1
routes/ideas.js View File

@@ -44,7 +44,9 @@ router.route('/')
44 44
 router.route('/')
45 45
   .get(go.get.highlyVoted, authorize.onlyLogged, parse, ideaValidators.getIdeasHighlyVoted, ideaControllers.getIdeasHighlyVoted);
46 46
 
47
-
47
+// get trending ideas
48
+router.route('/')
49
+  .get(go.get.trending, authorize.onlyLogged, parse, ideaValidators.getIdeasTrending, ideaControllers.getIdeasTrending);
48 50
 
49 51
 router.route('/:id')
50 52
   // read idea by id

+ 210
- 34
test/ideas.list.js View File

@@ -1,6 +1,10 @@
1 1
 'use strict';
2 2
 
3
-const should = require('should');
3
+const path = require('path'),
4
+      should = require('should'),
5
+      sinon = require('sinon');
6
+
7
+const  models = require(path.resolve('./models'));
4 8
 
5 9
 const agentFactory = require('./agent'),
6 10
       dbHandle = require('./handle-database');
@@ -424,7 +428,6 @@ describe('read lists of ideas', () => {
424 428
     beforeEach(async () => {
425 429
       const data = {
426 430
         users: 6,
427
-        tags: 6,
428 431
         verifiedUsers: [0, 1, 2, 3, 4],
429 432
         ideas: [[{}, 0], [{}, 0],[{}, 1],[{}, 2],[{}, 2],[{}, 2],[{}, 3]]
430 433
       };
@@ -520,6 +523,12 @@ describe('read lists of ideas', () => {
520 523
             .expect(400);
521 524
         });
522 525
 
526
+        it('[too many users] 400', async () => {
527
+          await agent
528
+            .get('/ideas?filter[creators]=user1,user2,user3,user4,user5,user6,user7,user8,user9,user190,user11')
529
+            .expect(400);
530
+        });
531
+
523 532
         it('[invalid pagination] 400', async () => {
524 533
           await agent
525 534
             .get(`/ideas?filter[creators]=${user2.username},${user3.username}&page[offset]=1&page[limit]=21`)
@@ -552,22 +561,8 @@ describe('read lists of ideas', () => {
552 561
     beforeEach(async () => {
553 562
       const data = {
554 563
         users: 6,
555
-        tags: 6,
556 564
         verifiedUsers: [0, 1, 2, 3, 4],
557
-        ideas: [[{}, 0], [{}, 0],[{}, 1],[{}, 2],[{}, 2],[{}, 2],[{}, 3]],
558
-        userTag: [
559
-          [0,0,'',5],[0,1,'',4],[0,2,'',3],[0,4,'',1],
560
-          [1,1,'',4],[1,3,'',2],
561
-          [2,5,'',2]
562
-        ],
563
-        ideaTags: [
564
-          [0,0],[0,1],[0,2],
565
-          [1,1],[1,2],
566
-          [2,1],[2,2],[2,4],
567
-          [4,0],[4,1],[4,2],[4,3],[4,4],
568
-          [5,2],[5,3],
569
-          [6,3]
570
-        ],
565
+        ideas: Array(7).fill([]),
571 566
         ideaComments: [[0, 0],[0, 1], [0,2],[0,2], [0,4], [1,1], [1,2], [2,1], [2,2], [3,4] ]
572 567
       };
573 568
 
@@ -662,6 +657,12 @@ describe('read lists of ideas', () => {
662 657
             .expect(400);
663 658
         });
664 659
 
660
+        it('[too many users] 400', async () => {
661
+          await agent
662
+            .get('/ideas?filter[commentedBy]=user1,user2,user3,user4,user5,user6,user7,user8,user9,user190,user11')
663
+            .expect(400);
664
+        });
665
+
665 666
         it('[invalid pagination] 400', async () => {
666 667
           await agent
667 668
             .get(`/ideas?filter[commentedBy]=${user2.username},${user3.username}&page[offset]=1&page[limit]=21`)
@@ -685,30 +686,16 @@ describe('read lists of ideas', () => {
685 686
     });
686 687
   });
687 688
 
688
-  describe('GET /ideas?filter[highlyVoted]', () => {
689
+  describe('GET /ideas?filter[highlyVoted]=voteSumBottomLimit', () => {
689 690
     let user0;
690 691
     // create and save testing data
691 692
     beforeEach(async () => {
692 693
       const primarys = 'ideas';
693 694
       const data = {
694 695
         users: 6,
695
-        tags: 6,
696 696
         verifiedUsers: [0, 1, 2, 3, 4],
697
-        ideas: [[{}, 0], [{}, 0],[{}, 1],[{}, 2],[{}, 2],[{}, 2],[{}, 3]],
698
-        userTag: [
699
-          [0,0,'',5],[0,1,'',4],[0,2,'',3],[0,4,'',1],
700
-          [1,1,'',4],[1,3,'',2],
701
-          [2,5,'',2]
702
-        ],
703
-        ideaTags: [
704
-          [0,0],[0,1],[0,2],
705
-          [1,1],[1,2],
706
-          [2,1],[2,2],[2,4],
707
-          [4,0],[4,1],[4,2],[4,3],[4,4],
708
-          [5,2],[5,3],
709
-          [6,3]
710
-        ],
711
-        // odeas with votes: 3:3, 1:3, 5:1, 2:1, 0:0, 6: -1, 4:-2
697
+        ideas: Array(7).fill([]),
698
+        // ideas with votes: 3:3, 1:3, 5:1, 2:1, 0:0, 6: -1, 4:-2
712 699
         votes: [
713 700
           [0, [primarys, 0], -1],
714 701
           [1, [primarys, 0],  1],
@@ -830,4 +817,193 @@ describe('read lists of ideas', () => {
830 817
       });
831 818
     });
832 819
   });
820
+
821
+  describe('GET /ideas?filter[trending]', () => {
822
+    let user0,
823
+        user1,
824
+        user2,
825
+        user3,
826
+        user4,
827
+        user5,
828
+        user6,
829
+        user7,
830
+        user8,
831
+        idea1,
832
+        idea2,
833
+        idea3,
834
+        idea4,
835
+        idea5,
836
+        idea6;
837
+    const now = Date.now();
838
+    let sandbox;
839
+    const threeMonths = 7776000000;
840
+    const threeWeeks = 1814400000;
841
+    const oneWeek = 604800000;
842
+    const twoDays = 172800000;
843
+    // create and save testing data
844
+    beforeEach(async () => {
845
+      sandbox = sinon.sandbox.create();
846
+      const primarys = 'ideas';
847
+      const data = {
848
+        users: 10,
849
+        verifiedUsers: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
850
+        ideas: Array(11).fill([]),
851
+        // odeas with votes: 3:3, 1:3, 5:1, 2:1, 0:0, 6: -1, 4:-2
852
+        votes: [
853
+          [0, [primarys, 1], 1],
854
+          [0, [primarys, 2], 1],
855
+          [0, [primarys, 3], 1],
856
+          [1, [primarys, 3], 1],
857
+          [2, [primarys, 3], 1],
858
+          [0, [primarys, 4], 1],
859
+          [1, [primarys, 4], 1],
860
+          [2, [primarys, 4], 1],
861
+          [3, [primarys, 4], 1],
862
+          [4, [primarys, 4], 1],
863
+          [0, [primarys, 5], 1],
864
+          [1, [primarys, 5], 1],
865
+          [2, [primarys, 5], 1],
866
+          [3, [primarys, 5], 1],
867
+          [4, [primarys, 5], 1],
868
+          [5, [primarys, 5], 1],
869
+          [6, [primarys, 5], 1],
870
+          [0, [primarys, 6], 1],
871
+          [1, [primarys, 6], 1]
872
+        ]
873
+      };
874
+      // post initial data and oldest votes with date three monts ago without two days
875
+      sandbox.useFakeTimers(now - threeMonths + twoDays);
876
+      dbData = await dbHandle.fill(data);
877
+
878
+      [user0, user1, user2, user3, user4, user5, user6, user7, user8 ] = dbData.users;
879
+      [ , idea1, idea2, idea3, idea4, idea5, idea6] = dbData.ideas;
880
+
881
+      // create data to post with time: three weeks ago
882
+      const dataThreeWeeksAgo = {
883
+        votes: [
884
+          {from: user1.username, to: {type: primarys, id: idea1.id}, value: 1},
885
+          {from: user1.username, to: {type: primarys, id: idea2.id}, value: 1},
886
+          {from: user2.username, to: {type: primarys, id: idea2.id}, value: 1},
887
+          {from: user3.username, to: {type: primarys, id: idea2.id}, value: 1},
888
+          {from: user3.username, to: {type: primarys, id: idea3.id}, value: 1},
889
+          {from: user5.username, to: {type: primarys, id: idea4.id}, value: 1},
890
+          {from: user6.username, to: {type: primarys, id: idea4.id}, value: 1},
891
+          {from: user7.username, to: {type: primarys, id: idea4.id}, value: 1},
892
+          {from: user7.username, to: {type: primarys, id: idea5.id}, value: 1},
893
+          {from: user2.username, to: {type: primarys, id: idea6.id}, value: 1},
894
+          {from: user3.username, to: {type: primarys, id: idea6.id}, value: 1}
895
+        ]
896
+      };
897
+      // stub time to three weeks ago without two days
898
+      sandbox.clock.restore();
899
+      sandbox.useFakeTimers(now - threeWeeks + twoDays);
900
+      // add data to database hree weeks ago without two days
901
+      for(const i in dataThreeWeeksAgo.votes){
902
+        await models.vote.create(dataThreeWeeksAgo.votes[i]);
903
+      }
904
+
905
+      const dataOneWeekAgo = {
906
+        votes: [
907
+          {from: user2.username, to: {type: primarys, id: idea1.id}, value: 1},
908
+          {from: user3.username, to: {type: primarys, id: idea1.id}, value: 1},
909
+          {from: user4.username, to: {type: primarys, id: idea1.id}, value: 1},
910
+          {from: user5.username, to: {type: primarys, id: idea1.id}, value: 1},
911
+          {from: user6.username, to: {type: primarys, id: idea1.id}, value: 1},
912
+          {from: user7.username, to: {type: primarys, id: idea1.id}, value: 1},
913
+          {from: user8.username, to: {type: primarys, id: idea1.id}, value: 1},
914
+          {from: user4.username, to: {type: primarys, id: idea2.id}, value: 1},
915
+          {from: user5.username, to: {type: primarys, id: idea2.id}, value: 1},
916
+          {from: user6.username, to: {type: primarys, id: idea2.id}, value: 1},
917
+          {from: user7.username, to: {type: primarys, id: idea2.id}, value: 1},
918
+          {from: user8.username, to: {type: primarys, id: idea2.id}, value: 1},
919
+          {from: user4.username, to: {type: primarys, id: idea3.id}, value: 1},
920
+          {from: user5.username, to: {type: primarys, id: idea3.id}, value: 1},
921
+          {from: user6.username, to: {type: primarys, id: idea3.id}, value: 1},
922
+          {from: user7.username, to: {type: primarys, id: idea3.id}, value: 1},
923
+          {from: user8.username, to: {type: primarys, id: idea3.id}, value: 1},
924
+          {from: user8.username, to: {type: primarys, id: idea4.id}, value: 1},
925
+          {from: user8.username, to: {type: primarys, id: idea5.id}, value: 1},
926
+          {from: user4.username, to: {type: primarys, id: idea6.id}, value: 1},
927
+          {from: user5.username, to: {type: primarys, id: idea6.id}, value: 1}
928
+        ]
929
+      };
930
+      // stub time to one week ago without two days
931
+      sandbox.clock.restore();
932
+      sandbox.useFakeTimers( now - oneWeek + twoDays);
933
+      for(const i in dataOneWeekAgo.votes){
934
+        await models.vote.create(dataOneWeekAgo.votes[i]);
935
+      }
936
+      sandbox.clock.restore();
937
+    });
938
+    afterEach(async () => {
939
+      sandbox.restore();
940
+    });
941
+
942
+    context('logged in', () => {
943
+
944
+      beforeEach(() => {
945
+        agent = agentFactory.logged(user0);
946
+      });
947
+
948
+      context('valid data', () => {
949
+
950
+        it('[trending] 200 and return array of matched ideas', async () => {
951
+          // request
952
+          const response = await agent
953
+            .get('/ideas?filter[trending]')
954
+            .expect(200);
955
+          // without pagination, limit for ideas 5 we should find 5 ideas...
956
+          should(response.body).have.property('data').Array().length(5);
957
+
958
+          // sorted by trending rate
959
+          should(response.body.data.map(idea => idea.attributes.title))
960
+            .eql([1, 2, 3, 6, 4].map(no => `idea title ${no}`));
961
+
962
+        });
963
+
964
+        it('[trending with pagination] offset and limit the results', async () => {
965
+          const response = await agent
966
+            .get('/ideas?filter[trending]&page[offset]=1&page[limit]=3')
967
+            .expect(200);
968
+
969
+          // we should find 3 ideas
970
+          should(response.body).have.property('data').Array().length(3);
971
+
972
+          // sorted by trending rate
973
+          should(response.body.data.map(idea => idea.attributes.title))
974
+            .eql([2, 3, 6].map(no => `idea title ${no}`));
975
+        });
976
+
977
+      });
978
+
979
+      context('invalid data', () => {
980
+
981
+        it('[trending invalid query.filter.highlyRated] 400', async () => {
982
+          await agent
983
+            .get('/ideas?filter[trending]=string&page[offset]=1&page[limit]=3')
984
+            .expect(400);
985
+        });
986
+
987
+        it('[trending invalid query.filter.highlyRated] 400', async () => {
988
+          await agent
989
+            .get('/ideas?filter[trending]=1&page[offset]=1&page[limit]=3')
990
+            .expect(400);
991
+        });
992
+
993
+        it('[unexpected query params] 400', async () => {
994
+          await agent
995
+            .get('/ideas?filter[trending]&additional[param]=3&page[offset]=1&page[limit]=3')
996
+            .expect(400);
997
+        });
998
+      });
999
+    });
1000
+
1001
+    context('not logged in', () => {
1002
+      it('403', async () => {
1003
+        await agent
1004
+          .get('/ideas?filter[trending]')
1005
+          .expect(403);
1006
+      });
1007
+    });
1008
+  });
833 1009
 });