Skip to content

Commit 2823b78

Browse files
authored
fix: add endpoint for featured collections (#1883)
* fix: add endpoint for featured collections * fix: add GRAASPER_CREATOR_ID to test env * fix: apply limit to hitsPerPage param * fix: make requested changes * fix: sort featured by name asc * fix: update expectation for featured collection * fix: limit changed name: * fix: tests
1 parent 96b403e commit 2823b78

7 files changed

Lines changed: 196 additions & 5 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ env:
1818
# random keys
1919
APPS_JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
2020
APPS_PUBLISHER_ID: 9c9cea73-f3b7-48a3-aa6e-ead82c0685e7 # mock uuid
21+
GRAASPER_CREATOR_ID: bbbf7cac-6139-45e4-8fbf-4cf767b50b29 # mock uuid
2122
AUTH_TOKEN_JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
2223
COOKIE_DOMAIN: localhost
2324
CLIENT_HOST: http://localhost:3114

src/services/item/plugins/publication/published/plugins/search/search.controller.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { db } from '../../../../../../../drizzle/db';
1818
import { itemsRawTable } from '../../../../../../../drizzle/schema';
1919
import { type ItemRaw } from '../../../../../../../drizzle/types';
2020
import { assertIsDefined } from '../../../../../../../utils/assertions';
21-
import { ITEMS_ROUTE_PREFIX } from '../../../../../../../utils/config';
21+
import { GRAASPER_CREATOR_ID, ITEMS_ROUTE_PREFIX } from '../../../../../../../utils/config';
2222
import { MeiliSearchWrapper } from './meilisearch';
2323

2424
jest.mock('./meilisearch');
@@ -498,6 +498,117 @@ describe('Collection Search endpoints', () => {
498498
});
499499
});
500500

501+
describe('GET /collections/featured', () => {
502+
it('get featured collections', async () => {
503+
// Meilisearch is mocked so format of API doesn't matter, we just want it to proxy MultiSearchParams;
504+
const fakeResponse = {
505+
results: [
506+
{
507+
indexUid: 'index',
508+
hits: [
509+
{
510+
name: 'Geogebra',
511+
description: 'Interactive tools from geogebra for mathematics.',
512+
content: '',
513+
creator: {
514+
id: GRAASPER_CREATOR_ID,
515+
name: 'Graasper',
516+
},
517+
level: [],
518+
discipline: [],
519+
'resource-type': [],
520+
id: v4(),
521+
type: 'folder',
522+
isPublishedRoot: true,
523+
isHidden: false,
524+
publicationUpdatedAt: '2021-10-20T13:03:42.712Z',
525+
createdAt: '2021-10-20T13:12:47.821Z',
526+
updatedAt: '2021-10-23T09:25:39.798Z',
527+
lang: 'en',
528+
likes: 9,
529+
_formatted: {
530+
name: 'Geogebra',
531+
description: 'Interactive tools from geogebra for mathematics.',
532+
content: '',
533+
creator: {
534+
id: GRAASPER_CREATOR_ID,
535+
name: 'Graasper',
536+
},
537+
level: [],
538+
discipline: [],
539+
'resource-type': [],
540+
id: v4(),
541+
type: 'folder',
542+
isPublishedRoot: true,
543+
isHidden: false,
544+
publicationUpdatedAt: '2021-10-20T13:03:42.712Z',
545+
createdAt: '2021-10-20T13:12:47.821Z',
546+
updatedAt: '2021-10-23T09:25:39.798Z',
547+
lang: 'en',
548+
likes: 9,
549+
},
550+
},
551+
{
552+
name: 'PhET',
553+
content: '',
554+
description: '',
555+
creator: {
556+
id: GRAASPER_CREATOR_ID,
557+
name: 'Graasper',
558+
},
559+
level: [],
560+
discipline: [],
561+
'resource-type': [],
562+
id: v4(),
563+
type: 'folder',
564+
isPublishedRoot: true,
565+
isHidden: false,
566+
publicationUpdatedAt: '2021-10-20T13:03:42.712Z',
567+
createdAt: '2021-10-20T13:03:42.712Z',
568+
updatedAt: '2021-11-10T10:49:39.296Z',
569+
lang: 'en',
570+
likes: 7,
571+
_formatted: {
572+
name: 'PhET',
573+
description: '',
574+
content: '',
575+
creator: {
576+
id: GRAASPER_CREATOR_ID,
577+
name: 'Graasper',
578+
},
579+
level: [],
580+
discipline: [],
581+
'resource-type': [],
582+
id: v4(),
583+
type: 'folder',
584+
isPublishedRoot: true,
585+
isHidden: false,
586+
publicationUpdatedAt: '2021-10-20T13:03:42.712Z',
587+
createdAt: '2021-10-20T13:03:42.712Z',
588+
updatedAt: '2021-11-10T10:49:39.296Z',
589+
lang: 'en',
590+
likes: 7,
591+
},
592+
},
593+
] as never[],
594+
processingTimeMs: 123,
595+
query: '',
596+
},
597+
],
598+
};
599+
jest.spyOn(MeiliSearchWrapper.prototype, 'search').mockResolvedValue(fakeResponse);
600+
const res = await app.inject({
601+
method: HttpMethod.Get,
602+
url: `${ITEMS_ROUTE_PREFIX}/collections/featured`,
603+
});
604+
expect(res.statusCode).toBe(StatusCodes.OK);
605+
// The creator should be Graasp (GRAASPER_CREATOR_ID)
606+
res.json().hits.forEach(({ creator }) => {
607+
expect(creator.id).toEqual(GRAASPER_CREATOR_ID);
608+
});
609+
});
610+
});
611+
501612
describe('GET /collections/liked', () => {
502613
it('get most liked items', async () => {
503614
// Meilisearch is mocked so format of API doesn't matter, we just want it to proxy MultiSearchParams;

src/services/item/plugins/publication/published/plugins/search/search.controller.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { ActionTriggers } from '@graasp/sdk';
66

77
import { resolveDependency } from '../../../../../../../di/utils';
88
import { db } from '../../../../../../../drizzle/db';
9-
import { MEILISEARCH_REBUILD_SECRET } from '../../../../../../../utils/config';
9+
import { GRAASPER_CREATOR_ID, MEILISEARCH_REBUILD_SECRET } from '../../../../../../../utils/config';
1010
import { ActionService } from '../../../../../../action/action.service';
1111
import { optionalIsAuthenticated } from '../../../../../../auth/plugins/passport';
12-
import { getFacets, getMostLiked, getMostRecent, search } from './search.schemas';
12+
import { getFacets, getFeatured, getMostLiked, getMostRecent, search } from './search.schemas';
1313
import { SearchService } from './search.service';
1414

1515
export type SearchFields = {
@@ -52,6 +52,15 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
5252
},
5353
);
5454

55+
fastify.get(
56+
'/collections/featured',
57+
{ preHandler: optionalIsAuthenticated, schema: getFeatured },
58+
async ({ query }) => {
59+
const searchResults = await searchService.getFeatured(GRAASPER_CREATOR_ID, query.limit);
60+
return searchResults;
61+
},
62+
);
63+
5564
fastify.get(
5665
'/collections/liked',
5766
{ preHandler: optionalIsAuthenticated, schema: getMostLiked },

src/services/item/plugins/publication/published/plugins/search/search.schemas.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ItemType, TagCategory } from '@graasp/sdk';
88
import { customType, registerSchemaAsRef } from '../../../../../../../plugins/typebox';
99
import { errorSchemaRef } from '../../../../../../../schemas/global';
1010
import {
11+
GET_FEATURED_ITEMS_MAXIMUM,
1112
GET_MOST_LIKED_ITEMS_MAXIMUM,
1213
GET_MOST_RECENT_ITEMS_MAXIMUM,
1314
} from '../../../../../../../utils/config';
@@ -98,6 +99,23 @@ export const search = {
9899
},
99100
} as const satisfies FastifySchema;
100101

102+
export const getFeatured = {
103+
operationId: 'getFeaturedCollections',
104+
tags: ['collection'],
105+
summary: 'Get featured collections',
106+
description: 'Get collections that we want to feature on the library home page.',
107+
108+
querystring: customType.StrictObject({
109+
limit: Type.Optional(
110+
Type.Number({ minimum: 1, maximum: GET_FEATURED_ITEMS_MAXIMUM, default: 12 }),
111+
),
112+
}),
113+
response: {
114+
[StatusCodes.OK]: meilisearchSearchResponseSchema,
115+
'4xx': errorSchemaRef,
116+
},
117+
} as const satisfies FastifySchema;
118+
101119
export const getMostLiked = {
102120
operationId: 'getMostLikedCollections',
103121
tags: ['collection', 'like'],

src/services/item/plugins/publication/published/plugins/search/search.service.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { v4 } from 'uuid';
22

33
import { MOCK_LOGGER } from '../../../../../../../../test/app';
4+
import { GRAASPER_CREATOR_ID } from '../../../../../../../utils/config';
45
import HookManager from '../../../../../../../utils/hook';
56
import { ItemService } from '../../../../../item.service';
67
import { ItemPublishedService } from '../../itemPublished.service';
@@ -165,6 +166,30 @@ describe('getMostRecent', () => {
165166
});
166167
});
167168

169+
describe('getFeatured', () => {
170+
afterEach(() => {
171+
jest.clearAllMocks();
172+
});
173+
it('apply creator and sort by publication updatedAt', async () => {
174+
const MOCK_RESULT = { hits: [] } as never;
175+
const spy = jest
176+
.spyOn(meilisearchClient, 'search')
177+
.mockResolvedValue({ results: [MOCK_RESULT] });
178+
const serviceSpy = jest.spyOn(searchService, 'search');
179+
180+
const results = await searchService.getFeatured(GRAASPER_CREATOR_ID, 4);
181+
expect(results).toEqual(MOCK_RESULT);
182+
183+
// verify the arguments passed to the search function inside the service
184+
const { creatorId } = serviceSpy.mock.calls[0][0];
185+
expect(creatorId).toEqual(GRAASPER_CREATOR_ID);
186+
// verify arguments passed to the meilisearch API
187+
const { sort, hitsPerPage } = spy.mock.calls[0][0].queries[0];
188+
expect(sort).toEqual(['name:asc']);
189+
expect(hitsPerPage).toEqual(4);
190+
});
191+
});
192+
168193
describe('getFacets', () => {
169194
afterEach(() => {
170195
jest.clearAllMocks();

src/services/item/plugins/publication/published/plugins/search/search.service.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TagCategory, TagCategoryType, UUID } from '@graasp/sdk';
66
import { TagRaw } from '../../../../../../../drizzle/types';
77
import { BaseLogger } from '../../../../../../../logger';
88
import {
9+
GET_FEATURED_ITEMS_MAXIMUM,
910
GET_MOST_LIKED_ITEMS_MAXIMUM,
1011
GET_MOST_RECENT_ITEMS_MAXIMUM,
1112
} from '../../../../../../../utils/config';
@@ -77,6 +78,17 @@ export class SearchService {
7778
return filters;
7879
}
7980

81+
async getFeatured(creatorId: string, limit: number = GET_FEATURED_ITEMS_MAXIMUM) {
82+
return await this.search({
83+
creatorId,
84+
hitsPerPage: limit,
85+
// order by most recently updated first
86+
sort: ['name:asc'],
87+
// only include top level content
88+
isPublishedRoot: true,
89+
});
90+
}
91+
8092
async getMostLiked(limit: number = GET_MOST_LIKED_ITEMS_MAXIMUM) {
8193
return await this.search({ sort: ['likes:desc'], limit });
8294
}
@@ -86,7 +98,16 @@ export class SearchService {
8698
}
8799

88100
async search(body: Omit<MultiSearchQuery, 'filter' | 'indexUid' | 'q'> & SearchFilters) {
89-
const { tags, langs, isPublishedRoot, query, creatorId, ...q } = body;
101+
const { tags, langs, isPublishedRoot, query, creatorId, limit, page, ...q } = body;
102+
// should allways use pagination arguments together
103+
// page + hitsPerPage
104+
// limit + offset
105+
if (limit && page) {
106+
throw new Error(
107+
"'page' used together with 'limit'. Use 'page' in combination with 'hitsPerPage'. Otherwise use 'limit' with 'offset'. For more information see: https://www.meilisearch.com/docs/guides/front_end/pagination",
108+
);
109+
}
110+
90111
const filters = this.buildFilters({
91112
creatorId,
92113
tags,
@@ -101,6 +122,8 @@ export class SearchService {
101122
indexUid: this.meilisearchClient.getActiveIndexName(),
102123
attributesToHighlight: ['*'],
103124
...q,
125+
limit,
126+
page,
104127
q: query,
105128
filter: filters,
106129
},

src/utils/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,10 @@ if (!process.env.APPS_PUBLISHER_ID) {
331331
throw new Error('APPS_PUBLISHER_ID is not defined');
332332
}
333333
export const APPS_PUBLISHER_ID = process.env.APPS_PUBLISHER_ID;
334+
if (!process.env.GRAASPER_CREATOR_ID) {
335+
throw new Error('GRAASPER_CREATOR_ID is not defined');
336+
}
337+
export const GRAASPER_CREATOR_ID = process.env.GRAASPER_CREATOR_ID;
334338

335339
// used for hashing password
336340
export const SALT_ROUNDS = 10;
@@ -345,7 +349,7 @@ export const RECAPTCHA_SECRET_ACCESS_KEY = process.env.RECAPTCHA_SECRET_ACCESS_K
345349
export const RECAPTCHA_VERIFY_LINK = 'https://www.google.com/recaptcha/api/siteverify';
346350
export const RECAPTCHA_SCORE_THRESHOLD = 0.5;
347351

348-
// todo: use env var?
352+
export const GET_FEATURED_ITEMS_MAXIMUM = 50;
349353
export const GET_MOST_LIKED_ITEMS_MAXIMUM = 50;
350354
export const GET_MOST_RECENT_ITEMS_MAXIMUM = 50;
351355

0 commit comments

Comments
 (0)