Skip to content

Commit 108ab48

Browse files
authored
Feat/20 liking unliking (#25)
* Resolve dep cycle * Add likes endpoints and tests * Add route to check if user has liked an entity * Implement like and unliking entities * Created liked_by view * Change husky to run tests pre-push
1 parent 1453b48 commit 108ab48

File tree

28 files changed

+1532
-67
lines changed

28 files changed

+1532
-67
lines changed

.husky/pre-commit .husky/pre-push

File renamed without changes.

backend/requests/likes-requests.rest

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
@postId = 65f34799838f561749795f94
3+
4+
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOiI2NWYyNWNmN2EwMDhlMzI2OTY2Mzc3MmYiLCJpYXQiOjE3MTA1NTk1MDEsImV4cCI6MTcxMDU2MzEwMX0.lNZkns3CWyu88knl6eUYslPEw9J9-sFFH43ItHzFgqE
5+
6+
POST http://localhost:3001/api/likes
7+
Content-Type: application/json
8+
Authorization: bearer {{token}}
9+
10+
{
11+
"entityId": "{{postId}}",
12+
"entityModel": "Post"
13+
}

backend/src/app.ts

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import postRouter from './routes/posts';
77
import userRouter from './routes/users';
88
import loginRouter from './routes/login';
99
import testRouter from './routes/tests';
10+
import likeRouter from './routes/likes';
1011
import { errorHandler } from './utils/middleware';
1112

1213
const { NODE_ENV } = process.env;
@@ -33,6 +34,8 @@ app.use(morgan('dev'));
3334
app.use('/api/posts', postRouter);
3435
app.use('/api/users', userRouter);
3536
app.use('/api/login', loginRouter);
37+
app.use('/api/likes', likeRouter);
38+
3639
if (NODE_ENV !== 'production') app.use('/api/test', testRouter);
3740
app.use(errorHandler);
3841
export default app;

backend/src/mongo/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable import/no-cycle */
21
import mongoose from 'mongoose';
32
import logger from '../utils/logger';
43

@@ -17,3 +16,4 @@ export { default as User } from './models/user';
1716
export { default as Post } from './models/post';
1817
export { default as Comment } from './models/comment';
1918
export { default as testMongodb } from './test-mongodb';
19+
export { default as Like } from './models/like';

backend/src/mongo/models/comment.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import mongoose from 'mongoose';
2-
import { Post } from '../index';
32

43
const commentSchema = new mongoose.Schema(
54
{
@@ -36,6 +35,8 @@ const commentSchema = new mongoose.Schema(
3635

3736
commentSchema.pre('remove', async function handleCommentDeletion(next) {
3837
const thisComment = this;
38+
const Post = this.model('Post');
39+
const Like = this.model('Like');
3940
const post = await Post.findById(thisComment.post);
4041
const isThisCommentAReply = Boolean(thisComment.parentComment);
4142
let deletedCommentsIds = [thisComment._id.toString()];
@@ -72,6 +73,8 @@ commentSchema.pre('remove', async function handleCommentDeletion(next) {
7273

7374
await post.save();
7475

76+
await Like.deleteMany({ 'likedEntity.id': { $in: deletedCommentsIds }, 'likedEntity.model': 'Comment' });
77+
7578
next();
7679
});
7780

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import mongoose from 'mongoose';
2+
3+
export default new mongoose.Schema(
4+
{
5+
url: {
6+
type: String,
7+
required: true,
8+
},
9+
publicId: {
10+
type: String,
11+
required: true,
12+
},
13+
},
14+
);

backend/src/mongo/models/like.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import mongoose from 'mongoose';
2+
3+
const likeSchema = new mongoose.Schema({
4+
user: {
5+
type: mongoose.Schema.Types.ObjectId,
6+
required: true,
7+
ref: 'User',
8+
},
9+
likedEntity: {
10+
id: {
11+
type: mongoose.Schema.Types.ObjectId,
12+
required: true,
13+
refPath: 'model',
14+
},
15+
model: {
16+
type: String,
17+
required: true,
18+
enum: ['Post', 'Comment'],
19+
},
20+
},
21+
}, {
22+
timestamps: true,
23+
});
24+
25+
likeSchema.set('toJSON', {
26+
transform: (_document, returnedObject) => {
27+
returnedObject.id = returnedObject._id.toString();
28+
delete returnedObject._id;
29+
delete returnedObject.__v;
30+
},
31+
});
32+
33+
export default mongoose.model('Like', likeSchema);

backend/src/mongo/models/post.ts

+14-21
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,5 @@
11
import mongoose from 'mongoose';
2-
import { Comment } from '../index';
3-
4-
export const imageSchema = new mongoose.Schema(
5-
{
6-
url: {
7-
type: String,
8-
required: true,
9-
},
10-
publicId: {
11-
type: String,
12-
required: true,
13-
},
14-
},
15-
);
2+
import imageSchema from './image-schema';
163

174
const postSchema = new mongoose.Schema(
185
{
@@ -35,10 +22,6 @@ const postSchema = new mongoose.Schema(
3522
ref: 'Comment',
3623
},
3724
],
38-
likes: {
39-
type: mongoose.Schema.Types.ObjectId,
40-
ref: 'User',
41-
},
4225
},
4326
{ timestamps: true },
4427
);
@@ -51,10 +34,20 @@ postSchema.set('toJSON', {
5134
},
5235
});
5336

54-
postSchema.pre('remove', async function deleteAllCommentsOfPost(next) {
55-
const post = this;
37+
postSchema.pre('remove', async function handlePostDeletion(next) {
38+
const thisPost = this;
39+
const Comment = this.model('Comment');
40+
const Like = this.model('Like');
41+
let postCommentIds = await Comment.find({ post: thisPost._id }).select('_id');
42+
43+
postCommentIds = postCommentIds.map(
44+
(comment: { _id: mongoose.Schema.Types.ObjectId }) => comment._id.toString(),
45+
);
46+
47+
await Comment.deleteMany({ post: thisPost._id });
48+
await Like.deleteMany({ 'likedEntity.id': thisPost._id, 'likedEntity.model': 'Post' });
49+
await Like.deleteMany({ 'likedEntity.id': { $in: postCommentIds }, 'likedEntity.model': 'Comment' });
5650

57-
await Comment.deleteMany({ post: post._id });
5851
next();
5952
});
6053

backend/src/mongo/models/user.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import mongoose from 'mongoose';
2-
import { imageSchema } from './post';
2+
import imageSchema from './image-schema';
33

44
const userSchema = new mongoose.Schema({
55
fullName: {

backend/src/routes/likes.ts

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import express from 'express';
2+
import { authenticator } from '../utils/middleware';
3+
import logger from '../utils/logger';
4+
import likeService from '../services/like-service';
5+
6+
const router = express.Router();
7+
8+
router.post('/', authenticator(), async (req, res, next) => {
9+
const userId = req.userToken!.id;
10+
11+
try {
12+
await likeService.addLike({
13+
userId,
14+
entityId: req.body.entityId,
15+
entityModel: req.body.entityModel,
16+
});
17+
18+
return res.status(201).end();
19+
} catch (error) {
20+
const errorMessage = logger.getErrorMessage(error);
21+
logger.error(errorMessage);
22+
23+
if (/not found/i.test(errorMessage)) {
24+
return res.status(404).send({ error: errorMessage });
25+
}
26+
27+
if (/same entity twice/i.test(errorMessage)) {
28+
return res.status(400).send({ error: errorMessage });
29+
}
30+
31+
return next(error);
32+
}
33+
});
34+
35+
router.delete('/:entityId', authenticator(), async (req, res, next) => {
36+
const userId = req.userToken!.id;
37+
const { entityId } = req.params;
38+
39+
try {
40+
await likeService.removeLikeByUserIdAndEntityId({
41+
user: userId,
42+
entityId,
43+
});
44+
45+
return res.status(204).end();
46+
} catch (error) {
47+
const errorMessage = logger.getErrorMessage(error);
48+
logger.error(errorMessage);
49+
50+
return next(error);
51+
}
52+
});
53+
54+
router.get('/:entityId/likeCount', async (req, res, next) => {
55+
const { entityId } = req.params;
56+
57+
try {
58+
const likeCount = await likeService.getLikeCountByEntityId(entityId);
59+
60+
return res.status(200).send({ likeCount });
61+
} catch (error) {
62+
const errorMessage = logger.getErrorMessage(error);
63+
logger.error(errorMessage);
64+
65+
return next(error);
66+
}
67+
});
68+
69+
router.get('/:entityId/likes', async (req, res, next) => {
70+
const { entityId } = req.params;
71+
72+
try {
73+
const likes = await likeService.getLikeUsersByEntityId(entityId);
74+
75+
return res.status(200).send({ likes });
76+
} catch (error) {
77+
const errorMessage = logger.getErrorMessage(error);
78+
logger.error(errorMessage);
79+
80+
return next(error);
81+
}
82+
});
83+
84+
router.get('/:entityId/hasLiked', authenticator(), async (req, res, next) => {
85+
const userId = req.userToken!.id;
86+
const { entityId } = req.params;
87+
88+
try {
89+
const hasLiked = await likeService.hasUserLikedEntity(userId, entityId);
90+
91+
return res.status(200).send({ hasLiked });
92+
} catch (error) {
93+
const errorMessage = logger.getErrorMessage(error);
94+
logger.error(errorMessage);
95+
96+
return next(error);
97+
}
98+
});
99+
100+
export default router;

backend/src/services/like-service.ts

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
Like, Post, Comment,
3+
} from '../mongo';
4+
import { NewLike } from '../types';
5+
6+
const addLike = async (newLikeFields: NewLike) => {
7+
let entity;
8+
9+
if (newLikeFields.entityModel === 'Post') {
10+
entity = await Post.findById(newLikeFields.entityId);
11+
} else if (newLikeFields.entityModel === 'Comment') {
12+
entity = await Comment.findById(newLikeFields.entityId);
13+
}
14+
15+
if (!entity) {
16+
throw new Error('Entity not found');
17+
}
18+
19+
const existingLike = await Like.findOne({
20+
'likedEntity.id': newLikeFields.entityId,
21+
user: newLikeFields.userId,
22+
});
23+
24+
if (existingLike) {
25+
throw new Error('Can not like the same entity twice');
26+
}
27+
28+
await Like.create({
29+
user: newLikeFields.userId,
30+
likedEntity: {
31+
id: newLikeFields.entityId,
32+
model: newLikeFields.entityModel,
33+
},
34+
});
35+
};
36+
37+
const removeLikeByUserIdAndEntityId = async (
38+
{ user, entityId } : { user: string, entityId: string, },
39+
) => {
40+
await Like.findOneAndDelete({
41+
'likedEntity.id': entityId,
42+
user,
43+
});
44+
};
45+
46+
const getLikeCountByEntityId = async (entityId: string) => {
47+
const likeCount = await Like.countDocuments({
48+
'likedEntity.id': entityId,
49+
});
50+
51+
return likeCount;
52+
};
53+
54+
const getLikeUsersByEntityId = async (entityId: string) => {
55+
const likes = await Like.find({
56+
'likedEntity.id': entityId,
57+
}).populate('user', 'username');
58+
59+
return likes.map((like) => like.user);
60+
};
61+
62+
const hasUserLikedEntity = async (userId: string, entityId: string) => {
63+
const like = await Like.findOne({
64+
'likedEntity.id': entityId,
65+
user: userId,
66+
});
67+
68+
return !!like;
69+
};
70+
71+
export default {
72+
addLike,
73+
removeLikeByUserIdAndEntityId,
74+
getLikeCountByEntityId,
75+
getLikeUsersByEntityId,
76+
hasUserLikedEntity,
77+
};

backend/src/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export interface NewComment {
2121
parentComment?: string, // ref -> Comment (the root comment)
2222
}
2323

24+
export interface NewLike {
25+
userId: string, // ref -> User
26+
entityId: string, // ref -> Post | Comment
27+
entityModel: 'Post' | 'Comment',
28+
}
29+
2430
export interface Post {
2531
id: string,
2632
creator: User, // ref -> User

0 commit comments

Comments
 (0)