Skip to content

Commit 5860f28

Browse files
feat(realworld-graphql): add favorite feature (#166)
Co-authored-by: Lam Ngoc Khuong <[email protected]>
1 parent 1cd664b commit 5860f28

File tree

10 files changed

+481
-21
lines changed

10 files changed

+481
-21
lines changed

apps/realworld-api/src/api/article/favorite/favorite.service.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,19 @@ export class FavoriteService {
4848
userId,
4949
);
5050

51-
// Remove the user from the list of favorited users
52-
article.favoritedBy = article.favoritedBy.filter(
53-
(favoritedBy) => favoritedBy.id !== user.id,
51+
// Check if the user has already favorited the article
52+
const hasFavorited = article.favoritedBy.some(
53+
(favoritedBy) => favoritedBy.id === user.id,
5454
);
5555

56-
await this.articleRepository.save(article);
56+
if (hasFavorited) {
57+
// Remove the user from the list of favorited users
58+
article.favoritedBy = article.favoritedBy.filter(
59+
(favoritedBy) => favoritedBy.id !== user.id,
60+
);
61+
62+
await this.articleRepository.save(article);
63+
}
5764

5865
return {
5966
article: {

apps/realworld-graphql/src/modules/article/article.loader.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ export class ArticleDataLoader {
2323
if (!this.authorLoader) {
2424
this.authorLoader = new DataLoader<number, UserEntity>(
2525
async (authorIds: readonly number[]) => {
26-
const authors = await this.userRepository.findBy({
27-
id: In([...authorIds]),
26+
const authors = await this.userRepository.find({
27+
where: { id: In([...authorIds]) },
28+
relations: ['followers'],
2829
});
2930
return authorIds.map((id) =>
3031
authors.find((author) => author.id === id),

apps/realworld-graphql/src/modules/article/article.module.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ import { ArticleEntity, TagEntity, UserEntity } from '@repo/postgresql-typeorm';
44
import { ArticleDataLoader } from './article.loader';
55
import { ArticleResolver } from './article.resolver';
66
import { ArticleService } from './article.service';
7+
import { FavoriteModule } from './favorite/favorite.module';
78

89
@Module({
9-
imports: [TypeOrmModule.forFeature([ArticleEntity, TagEntity, UserEntity])],
10+
imports: [
11+
FavoriteModule,
12+
TypeOrmModule.forFeature([ArticleEntity, TagEntity, UserEntity]),
13+
],
1014
providers: [ArticleResolver, ArticleService, ArticleDataLoader],
1115
exports: [ArticleService],
1216
})

apps/realworld-graphql/src/modules/article/article.resolver.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Logger } from '@nestjs/common';
12
import {
23
Args,
34
Info,
@@ -21,6 +22,7 @@ import { Article } from './model/article.model';
2122

2223
@Resolver(() => Article)
2324
export class ArticleResolver {
25+
private readonly logger = new Logger(ArticleResolver.name);
2426
constructor(
2527
private readonly articleService: ArticleService,
2628
private readonly dataLoader: ArticleDataLoader,
@@ -35,6 +37,7 @@ export class ArticleResolver {
3537
@Args() { slug }: SlugArgs,
3638
@Info() info: GraphQLResolveInfo,
3739
): Promise<Article> {
40+
this.logger.log('Getting article', slug);
3841
const requestedFields = getFieldNames(info);
3942

4043
const shouldEagerLoad = ['author', 'favorited', 'favoritesCount'].every(
@@ -90,16 +93,19 @@ export class ArticleResolver {
9093
}
9194

9295
@ResolveField(() => Profile)
93-
async author(@Parent() article: Article): Promise<Profile> {
96+
async author(
97+
@CurrentUser('id') userId: number,
98+
@Parent() article: Article,
99+
): Promise<Profile> {
94100
if (article.author) return article.author;
101+
this.logger.log('Getting author for article', article.id);
95102
const author = await this.dataLoader
96103
.getAuthorLoader()
97104
.load(article.authorId);
98105
const profile = author.toDto(Profile);
99106
profile.following =
100-
author?.following?.some(
101-
(followee) => followee.followeeId === article?.author?.id,
102-
) || false;
107+
author?.followers?.some((follower) => follower.followerId === userId) ||
108+
false;
103109
return profile;
104110
}
105111

@@ -109,6 +115,7 @@ export class ArticleResolver {
109115
@CurrentUser('id') userId: number,
110116
) {
111117
if (article.favorited !== undefined) return article.favorited;
118+
this.logger.log('Getting favorited for article', article.id);
112119
const { favorited } = await this.dataLoader
113120
.getFavoritesLoader(userId)
114121
.load(article.id);
@@ -121,6 +128,7 @@ export class ArticleResolver {
121128
@CurrentUser('id') userId: number,
122129
) {
123130
if (article.favoritesCount !== undefined) return article.favoritesCount;
131+
this.logger.log('Getting favorites count for article', article.id);
124132
const { count } = await this.dataLoader
125133
.getFavoritesLoader(userId)
126134
.load(article.id);

apps/realworld-graphql/src/modules/article/article.service.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export class ArticleService {
9999
if (shouldEagerLoad) {
100100
article = await this.articleRepository.findOne({
101101
where: { id: savedArticle.id },
102-
relations: ['author', 'author.following', 'tags', 'favoritedBy'],
102+
relations: ['author', 'author.followers', 'tags', 'favoritedBy'],
103103
});
104104
} else {
105105
article = await this.articleRepository.findOne({
@@ -116,8 +116,8 @@ export class ArticleService {
116116
username: article.author.username,
117117
bio: article.author.bio,
118118
image: article.author.image,
119-
following: article.author.following.some(
120-
(follow) => follow.followeeId === userId,
119+
following: article.author.followers.some(
120+
(follower) => follower.followerId === userId,
121121
),
122122
},
123123
favorited: article.favoritedBy.some((user) => user.id === userId),
@@ -169,7 +169,7 @@ export class ArticleService {
169169
if (shouldEagerLoad) {
170170
newArticle = await this.articleRepository.findOne({
171171
where: { id: savedArticle.id },
172-
relations: ['author', 'author.following', 'tags', 'favoritedBy'],
172+
relations: ['author', 'author.followers', 'tags', 'favoritedBy'],
173173
});
174174
} else {
175175
newArticle = await this.articleRepository.findOne({
@@ -186,8 +186,8 @@ export class ArticleService {
186186
username: newArticle.author.username,
187187
bio: newArticle.author.bio,
188188
image: newArticle.author.image,
189-
following: newArticle.author.following.some(
190-
(follow) => follow.followeeId === userId,
189+
following: newArticle.author.followers.some(
190+
(follower) => follower.followerId === userId,
191191
),
192192
},
193193
favorited: newArticle.favoritedBy.some((user) => user.id === userId),
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { ArticleEntity, UserEntity } from '@repo/postgresql-typeorm';
4+
import { FavoriteResolver } from './favorite.resolver';
5+
import { FavoriteService } from './favorite.service';
26

37
@Module({
4-
imports: [],
5-
controllers: [],
6-
providers: [],
8+
imports: [TypeOrmModule.forFeature([ArticleEntity, UserEntity])],
9+
providers: [FavoriteResolver, FavoriteService],
710
})
811
export class FavoriteModule {}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Args, Info, Mutation, Resolver } from '@nestjs/graphql';
2+
import { CurrentUser } from '@repo/graphql/decorators/current-user.decorator';
3+
import { getFieldNames } from '@repo/graphql/utils/graphql-fields.util';
4+
import type { GraphQLResolveInfo } from 'graphql';
5+
import { Article } from '../model/article.model';
6+
import { FavoriteService } from './favorite.service';
7+
8+
@Resolver(() => Article)
9+
export class FavoriteResolver {
10+
constructor(private readonly favoriteService: FavoriteService) {}
11+
12+
@Mutation(() => Article, {
13+
name: 'favoriteArticle',
14+
description: 'Favorite an article',
15+
})
16+
async create(
17+
@CurrentUser('id') userId: number,
18+
@Args('slug') slug: string,
19+
@Info() info: GraphQLResolveInfo,
20+
): Promise<Article> {
21+
const requestedFields = getFieldNames(info);
22+
23+
const shouldEagerLoad = ['favorited', 'favoritesCount'].every((field) =>
24+
requestedFields.includes(field),
25+
);
26+
27+
return await this.favoriteService.create(slug, userId, shouldEagerLoad);
28+
}
29+
30+
@Mutation(() => Article, {
31+
name: 'unfavoriteArticle',
32+
description: 'Unfavorite an article',
33+
})
34+
async delete(
35+
@CurrentUser('id') userId: number,
36+
@Args('slug') slug: string,
37+
@Info() info: GraphQLResolveInfo,
38+
): Promise<Article> {
39+
const requestedFields = getFieldNames(info);
40+
41+
const shouldEagerLoad = ['favorited', 'favoritesCount'].every((field) =>
42+
requestedFields.includes(field),
43+
);
44+
45+
return await this.favoriteService.delete(slug, userId, shouldEagerLoad);
46+
}
47+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { ErrorCode } from '@/constants/error-code.constant';
2+
import { Injectable, Logger } from '@nestjs/common';
3+
import { InjectRepository } from '@nestjs/typeorm';
4+
import { ValidationException } from '@repo/graphql/exceptions/validation.exception';
5+
import { ArticleEntity, UserEntity } from '@repo/postgresql-typeorm';
6+
import { Repository } from 'typeorm';
7+
import { Article } from '../model/article.model';
8+
@Injectable()
9+
export class FavoriteService {
10+
private readonly logger = new Logger(FavoriteService.name);
11+
constructor(
12+
@InjectRepository(ArticleEntity)
13+
private readonly articleRepository: Repository<ArticleEntity>,
14+
@InjectRepository(UserEntity)
15+
private readonly userRepository: Repository<UserEntity>,
16+
) {}
17+
18+
async create(
19+
slug: string,
20+
userId: number,
21+
shouldEagerLoad: boolean,
22+
): Promise<Article> {
23+
const { user, article } = await this.validateAndGetUserArticle(
24+
slug,
25+
userId,
26+
shouldEagerLoad,
27+
);
28+
29+
// Check if the user has already favorited the article
30+
const hasFavorited = article.favoritedBy.some(
31+
(favoritedBy) => favoritedBy.id === user.id,
32+
);
33+
34+
if (!hasFavorited) {
35+
article.favoritedBy.push(user);
36+
await this.articleRepository.save(article);
37+
38+
// If you want to use the raw query, you can use the following code
39+
// await this.dataSource
40+
// .createQueryBuilder()
41+
// .insert()
42+
// .into('user_favorites')
43+
// .values({
44+
// article_id: article.id,
45+
// user_id: user.id,
46+
// })
47+
// .execute();
48+
}
49+
50+
return {
51+
...article.toDto(Article),
52+
tagList: article?.tags?.map((tag) => tag.name).reverse() || [],
53+
favorited: true,
54+
favoritesCount: article.favoritedBy.length,
55+
...(shouldEagerLoad && {
56+
author: {
57+
username: article.author.username,
58+
bio: article.author.bio,
59+
image: article.author.image,
60+
following: article.author.followers.some(
61+
(follower) => follower.followerId === userId,
62+
),
63+
},
64+
}),
65+
};
66+
}
67+
68+
async delete(
69+
slug: string,
70+
userId: number,
71+
shouldEagerLoad: boolean,
72+
): Promise<Article> {
73+
this.logger.log('Deleting favorite for article', slug);
74+
const { user, article } = await this.validateAndGetUserArticle(
75+
slug,
76+
userId,
77+
shouldEagerLoad,
78+
);
79+
80+
// Check if the user has already favorited the article
81+
const hasFavorited = article.favoritedBy.some(
82+
(favoritedBy) => favoritedBy.id === user.id,
83+
);
84+
85+
if (hasFavorited) {
86+
// Remove the user from the list of favorited users
87+
article.favoritedBy = article.favoritedBy.filter(
88+
(favoritedBy) => favoritedBy.id !== user.id,
89+
);
90+
91+
await this.articleRepository.save(article);
92+
}
93+
94+
return {
95+
...article.toDto(Article),
96+
tagList: article?.tags?.map((tag) => tag.name).reverse() || [],
97+
favorited: false,
98+
favoritesCount: article.favoritedBy.length,
99+
...(shouldEagerLoad && {
100+
author: {
101+
username: article.author.username,
102+
bio: article.author.bio,
103+
image: article.author.image,
104+
following: article.author.followers.some(
105+
(follower) => follower.followerId === userId,
106+
),
107+
},
108+
}),
109+
};
110+
}
111+
112+
private async validateAndGetUserArticle(
113+
slug: string,
114+
userId: number,
115+
shouldEagerLoad: boolean,
116+
): Promise<{ user: UserEntity; article: ArticleEntity }> {
117+
const user = await this.userRepository.findOneOrFail({
118+
where: { id: userId },
119+
});
120+
let article: ArticleEntity;
121+
if (shouldEagerLoad) {
122+
article = await this.articleRepository.findOne({
123+
where: { slug },
124+
relations: ['author', 'author.followers', 'tags', 'favoritedBy'],
125+
});
126+
} else {
127+
article = await this.articleRepository.findOne({
128+
where: { slug },
129+
relations: ['tags', 'favoritedBy'],
130+
});
131+
}
132+
133+
if (!article) {
134+
throw new ValidationException(ErrorCode.E201);
135+
}
136+
137+
return { user, article };
138+
}
139+
}

apps/realworld-graphql/src/schema.gql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ type Mutation {
5757
createUser(input: CreateUserInput!): User!
5858
deleteArticle(slug: String!): Boolean!
5959

60+
"""Favorite an article"""
61+
favoriteArticle(slug: String!): Article!
62+
6063
"""Follow User"""
6164
followUser(
6265
"""Username of the profile"""
@@ -66,6 +69,9 @@ type Mutation {
6669
"""Sign in"""
6770
login(input: LoginInput!): User!
6871

72+
"""Unfavorite an article"""
73+
unfavoriteArticle(slug: String!): Article!
74+
6975
"""Unfollow User"""
7076
unfollowUser(
7177
"""Username of the profile"""

0 commit comments

Comments
 (0)