Skip to content

Commit 6148bfa

Browse files
authored
feat: add likes in meilisearch (#1715)
* feat: add likes in meilisearch * refactor: refactor * fix: add migration for index * refactor: fix test
1 parent bdea75e commit 6148bfa

13 files changed

Lines changed: 329 additions & 15 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"@fastify/type-provider-typebox": "4.1.0",
6767
"@fastify/websocket": "7.2.0",
6868
"@graasp/etherpad-api": "2.1.1",
69-
"@graasp/sdk": "5.5.0",
69+
"@graasp/sdk": "5.6.0",
7070
"@graasp/translations": "1.42.0",
7171
"@rapideditor/country-coder": "5.2.2",
7272
"@sentry/node": "7.119.2",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class Migrations1736512586095 implements MigrationInterface {
4+
name = 'item-like-index-item-1736512586095';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`CREATE INDEX "IDX_item_like_item" ON "item_like" ("item_id") `);
8+
}
9+
10+
public async down(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query(`DROP INDEX "public"."IDX_item_like_item"`);
12+
}
13+
}

src/services/action/entities/action.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class Action extends BaseEntity {
3838
})
3939
@JoinColumn({ referencedColumnName: 'id', name: 'item_id' })
4040
item?: Item | null;
41+
4142
@Column({
4243
nullable: false,
4344
enum: Object.values(Context),

src/services/item/plugins/itemLike/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
1919
const { db } = fastify;
2020

2121
const itemService = resolveDependency(ItemService);
22-
const itemLikeService = new ItemLikeService(itemService);
22+
const itemLikeService = resolveDependency(ItemLikeService);
2323
const actionService = resolveDependency(ActionService);
2424

2525
//get liked entry for member

src/services/item/plugins/itemLike/itemLike.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
BaseEntity,
33
CreateDateColumn,
44
Entity,
5+
Index,
56
JoinColumn,
67
ManyToOne,
78
PrimaryGeneratedColumn,
@@ -25,6 +26,7 @@ export class ItemLike extends BaseEntity {
2526
@JoinColumn({ name: 'creator_id' })
2627
creator: Member;
2728

29+
@Index('IDX_item_like_item')
2830
@ManyToOne(() => Item, (item) => item.id, {
2931
onDelete: 'CASCADE',
3032
nullable: false,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { DataSource, Repository } from 'typeorm';
2+
import { v4 } from 'uuid';
3+
4+
import { FolderItemFactory, MemberFactory } from '@graasp/sdk';
5+
6+
import { AppDataSource } from '../../../../plugins/datasource';
7+
import { IllegalArgumentException } from '../../../../repositories/errors';
8+
import { Member } from '../../../member/entities/member';
9+
import { Item } from '../../entities/Item';
10+
import { ItemLike } from './itemLike';
11+
import { ItemLikeRepository } from './repository';
12+
13+
describe('Tag Repository', () => {
14+
let db: DataSource;
15+
16+
let repository: ItemLikeRepository;
17+
let likeRawRepository: Repository<ItemLike>;
18+
let itemRawRepository: Repository<Item>;
19+
let memberRawRepository: Repository<Member>;
20+
21+
beforeAll(async () => {
22+
db = await AppDataSource.initialize();
23+
await db.runMigrations();
24+
repository = new ItemLikeRepository(db.manager);
25+
likeRawRepository = db.getRepository(ItemLike);
26+
itemRawRepository = db.getRepository(Item);
27+
memberRawRepository = db.getRepository(Member);
28+
});
29+
30+
afterAll(async () => {
31+
await db.dropDatabase();
32+
await db.destroy();
33+
});
34+
35+
describe('getByItemIdId', () => {
36+
it('throw for invalid id', async () => {
37+
await expect(() => repository.getByItemId(undefined!)).rejects.toBeInstanceOf(
38+
IllegalArgumentException,
39+
);
40+
});
41+
it('Return null for non-existing item', async () => {
42+
expect(await repository.getByItemId(v4())).toEqual([]);
43+
});
44+
it('Return null for no like for item', async () => {
45+
expect(await repository.getByItemId(v4())).toEqual([]);
46+
});
47+
it('get likes', async () => {
48+
const item = await itemRawRepository.save(FolderItemFactory({ creator: null }));
49+
const creator = await memberRawRepository.save(MemberFactory());
50+
const l1 = await likeRawRepository.save({ item, creator });
51+
const creator1 = await memberRawRepository.save(MemberFactory());
52+
const l2 = await likeRawRepository.save({ item, creator: creator1 });
53+
54+
const result = await repository.getByItemId(item.id);
55+
expect(result).toHaveLength(2);
56+
[l1, l2].forEach((like) => {
57+
// should contain item
58+
expect(result.find(({ id }) => id === like.id)!.item.id).toEqual(like.item.id);
59+
});
60+
});
61+
});
62+
63+
describe('getCountByItemId', () => {
64+
it('throw for invalid id', async () => {
65+
await expect(() => repository.getCountByItemId(undefined!)).rejects.toBeInstanceOf(
66+
IllegalArgumentException,
67+
);
68+
});
69+
it('Return 0 for non-existing item', async () => {
70+
expect(await repository.getCountByItemId(v4())).toEqual(0);
71+
});
72+
it('Return 0 for no like for item', async () => {
73+
expect(await repository.getCountByItemId(v4())).toEqual(0);
74+
});
75+
it('get like count', async () => {
76+
const item = await itemRawRepository.save(FolderItemFactory({ creator: null }));
77+
const creator = await memberRawRepository.save(MemberFactory());
78+
await likeRawRepository.save({ item, creator });
79+
const creator1 = await memberRawRepository.save(MemberFactory());
80+
await likeRawRepository.save({ item, creator: creator1 });
81+
82+
expect(await repository.getCountByItemId(item.id)).toEqual(2);
83+
});
84+
});
85+
});

src/services/item/plugins/itemLike/repository.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export class ItemLikeRepository extends MutableRepository<ItemLike, never> {
6565
* Get likes for item
6666
* @param itemId
6767
*/
68-
async getByItem(itemId: ItemId): Promise<ItemLike[]> {
68+
async getByItemId(itemId: ItemId): Promise<ItemLike[]> {
6969
this.throwsIfParamIsInvalid('itemId', itemId);
7070
return await this.repository
7171
.createQueryBuilder('itemLike')
@@ -74,6 +74,19 @@ export class ItemLikeRepository extends MutableRepository<ItemLike, never> {
7474
.getMany();
7575
}
7676

77+
/**
78+
* Get likes count for item
79+
* @param itemId
80+
* @returns number of likes for item
81+
*/
82+
async getCountByItemId(itemId: ItemId): Promise<number> {
83+
this.throwsIfParamIsInvalid('itemId', itemId);
84+
return await this.repository
85+
.createQueryBuilder('itemLike')
86+
.where('itemLike.item_id = :itemId', { itemId })
87+
.getCount();
88+
}
89+
7790
/**
7891
* delete an item like
7992
* @param creatorId user's id
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { v4 } from 'uuid';
2+
3+
import { TagFactory } from '@graasp/sdk';
4+
5+
import { Repositories } from '../../../../utils/repositories';
6+
import { Member } from '../../../member/entities/member';
7+
import { Tag } from '../../../tag/Tag.entity';
8+
import { TagRepository } from '../../../tag/Tag.repository';
9+
import { Item } from '../../entities/Item';
10+
import { ItemService } from '../../service';
11+
import { ItemPublished } from '../publication/published/entities/itemPublished';
12+
import { MeiliSearchWrapper } from '../publication/published/plugins/search/meilisearch';
13+
import { ItemPublishedRepository } from '../publication/published/repositories/itemPublished';
14+
import { ItemLike } from './itemLike';
15+
import { ItemLikeRepository } from './repository';
16+
import { ItemLikeService } from './service';
17+
18+
const itemService = { get: jest.fn() as ItemService['get'] } as ItemService;
19+
const meilisearchWrapper = {
20+
updateItem: jest.fn() as MeiliSearchWrapper['updateItem'],
21+
} as MeiliSearchWrapper;
22+
23+
const itemLikeService = new ItemLikeService(itemService, meilisearchWrapper);
24+
25+
const repositories = {
26+
itemLikeRepository: {
27+
addOne: jest.fn() as ItemLikeRepository['addOne'],
28+
deleteOneByCreatorAndItem: jest.fn() as ItemLikeRepository['deleteOneByCreatorAndItem'],
29+
getCountByItemId: jest.fn() as ItemLikeRepository['getCountByItemId'],
30+
} as ItemLikeRepository,
31+
tagRepository: {
32+
addOneIfDoesNotExist: jest.fn(async () => TagFactory() as Tag),
33+
} as unknown as TagRepository,
34+
itemPublishedRepository: {
35+
getForItem: jest.fn() as ItemPublishedRepository['getForItem'],
36+
} as ItemPublishedRepository,
37+
} as Repositories;
38+
39+
const MOCK_LIKE = { creator: { id: v4() }, item: { id: v4() } } as ItemLike;
40+
41+
describe('Item Like post', () => {
42+
afterEach(() => {
43+
jest.clearAllMocks();
44+
});
45+
it('does not update like count for indexed item if it is not published', async () => {
46+
jest.spyOn(itemService, 'get').mockResolvedValue({} as Item);
47+
jest.spyOn(repositories.itemLikeRepository, 'addOne').mockResolvedValue(MOCK_LIKE);
48+
const updateItemMock = jest.spyOn(meilisearchWrapper, 'updateItem').mockResolvedValue();
49+
jest.spyOn(repositories.itemPublishedRepository, 'getForItem').mockResolvedValue(null);
50+
51+
await itemLikeService.post({} as Member, repositories, v4());
52+
53+
expect(updateItemMock).not.toHaveBeenCalled();
54+
});
55+
56+
it('update like count for indexed item if it is published', async () => {
57+
jest.spyOn(itemService, 'get').mockResolvedValue({} as Item);
58+
jest.spyOn(repositories.itemLikeRepository, 'addOne').mockResolvedValue(MOCK_LIKE);
59+
const updateItemMock = jest.spyOn(meilisearchWrapper, 'updateItem').mockResolvedValue();
60+
jest
61+
.spyOn(repositories.itemPublishedRepository, 'getForItem')
62+
.mockResolvedValue({} as unknown as ItemPublished);
63+
64+
await itemLikeService.post({} as Member, repositories, v4());
65+
66+
expect(updateItemMock).toHaveBeenCalled();
67+
});
68+
});
69+
70+
describe('Item Like removeOne', () => {
71+
afterEach(() => {
72+
jest.clearAllMocks();
73+
});
74+
it('do not update like count for indexed item if it is not published', async () => {
75+
jest.spyOn(itemService, 'get').mockResolvedValue({} as Item);
76+
jest
77+
.spyOn(repositories.itemLikeRepository, 'deleteOneByCreatorAndItem')
78+
.mockResolvedValue(MOCK_LIKE);
79+
const updateItemMock = jest.spyOn(meilisearchWrapper, 'updateItem').mockResolvedValue();
80+
jest.spyOn(repositories.itemPublishedRepository, 'getForItem').mockResolvedValue(null);
81+
82+
await itemLikeService.removeOne({} as Member, repositories, v4());
83+
84+
expect(updateItemMock).not.toHaveBeenCalled();
85+
});
86+
87+
it('update like count for indexed item if it is published', async () => {
88+
jest.spyOn(itemService, 'get').mockResolvedValue({} as Item);
89+
jest
90+
.spyOn(repositories.itemLikeRepository, 'deleteOneByCreatorAndItem')
91+
.mockResolvedValue(MOCK_LIKE);
92+
const updateItemMock = jest.spyOn(meilisearchWrapper, 'updateItem').mockResolvedValue();
93+
jest
94+
.spyOn(repositories.itemPublishedRepository, 'getForItem')
95+
.mockResolvedValue({} as unknown as ItemPublished);
96+
97+
await itemLikeService.removeOne({} as Member, repositories, v4());
98+
99+
expect(updateItemMock).toHaveBeenCalled();
100+
});
101+
});

src/services/item/plugins/itemLike/service.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1+
import { singleton } from 'tsyringe';
2+
13
import { Repositories } from '../../../../utils/repositories';
24
import { filterOutPackedItems } from '../../../authorization';
35
import { ItemService } from '../../../item/service';
46
import { Actor, Member } from '../../../member/entities/member';
7+
import { MeiliSearchWrapper } from '../publication/published/plugins/search/meilisearch';
58

9+
@singleton()
610
export class ItemLikeService {
711
private itemService: ItemService;
12+
private readonly meilisearchClient: MeiliSearchWrapper;
813

9-
constructor(itemService: ItemService) {
14+
constructor(itemService: ItemService, meilisearchClient: MeiliSearchWrapper) {
1015
this.itemService = itemService;
16+
this.meilisearchClient = meilisearchClient;
1117
}
1218

1319
async getForMember(member: Member, repositories: Repositories) {
@@ -35,23 +41,41 @@ export class ItemLikeService {
3541

3642
await this.itemService.get(actor, repositories, itemId);
3743

38-
return itemLikeRepository.getByItem(itemId);
44+
return itemLikeRepository.getByItemId(itemId);
3945
}
4046

4147
async removeOne(member: Member, repositories: Repositories, itemId: string) {
42-
const { itemLikeRepository } = repositories;
48+
const { itemLikeRepository, itemPublishedRepository } = repositories;
4349

4450
// QUESTION: allow public to be liked?
4551
const item = await this.itemService.get(member, repositories, itemId);
4652

47-
return itemLikeRepository.deleteOneByCreatorAndItem(member.id, item.id);
53+
const result = await itemLikeRepository.deleteOneByCreatorAndItem(member.id, item.id);
54+
55+
// update index if item is published
56+
const isPublished = await itemPublishedRepository.getForItem(item);
57+
if (isPublished) {
58+
const likes = await itemLikeRepository.getCountByItemId(item.id);
59+
await this.meilisearchClient.updateItem(item.id, { likes });
60+
}
61+
62+
return result;
4863
}
4964

5065
async post(member: Member, repositories: Repositories, itemId: string) {
51-
const { itemLikeRepository } = repositories;
66+
const { itemLikeRepository, itemPublishedRepository } = repositories;
5267

5368
// QUESTION: allow public to be liked?
5469
const item = await this.itemService.get(member, repositories, itemId);
55-
return itemLikeRepository.addOne({ creatorId: member.id, itemId: item.id });
70+
const result = await itemLikeRepository.addOne({ creatorId: member.id, itemId: item.id });
71+
72+
// update index if item is published
73+
const isPublished = await itemPublishedRepository.getForItem(item);
74+
if (isPublished) {
75+
const likes = await itemLikeRepository.getCountByItemId(item.id);
76+
await this.meilisearchClient.updateItem(item.id, { likes });
77+
}
78+
79+
return result;
5680
}
5781
}

0 commit comments

Comments
 (0)