Skip to content

Commit

Permalink
Feat/20 liking unliking (#25)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
lyncasterc authored Mar 17, 2024
1 parent 1453b48 commit 108ab48
Show file tree
Hide file tree
Showing 28 changed files with 1,532 additions and 67 deletions.
File renamed without changes.
13 changes: 13 additions & 0 deletions backend/requests/likes-requests.rest
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

@postId = 65f34799838f561749795f94

@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOiI2NWYyNWNmN2EwMDhlMzI2OTY2Mzc3MmYiLCJpYXQiOjE3MTA1NTk1MDEsImV4cCI6MTcxMDU2MzEwMX0.lNZkns3CWyu88knl6eUYslPEw9J9-sFFH43ItHzFgqE

POST http://localhost:3001/api/likes
Content-Type: application/json
Authorization: bearer {{token}}

{
"entityId": "{{postId}}",
"entityModel": "Post"
}
3 changes: 3 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import postRouter from './routes/posts';
import userRouter from './routes/users';
import loginRouter from './routes/login';
import testRouter from './routes/tests';
import likeRouter from './routes/likes';
import { errorHandler } from './utils/middleware';

const { NODE_ENV } = process.env;
Expand All @@ -33,6 +34,8 @@ app.use(morgan('dev'));
app.use('/api/posts', postRouter);
app.use('/api/users', userRouter);
app.use('/api/login', loginRouter);
app.use('/api/likes', likeRouter);

if (NODE_ENV !== 'production') app.use('/api/test', testRouter);
app.use(errorHandler);
export default app;
2 changes: 1 addition & 1 deletion backend/src/mongo/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable import/no-cycle */
import mongoose from 'mongoose';
import logger from '../utils/logger';

Expand All @@ -17,3 +16,4 @@ export { default as User } from './models/user';
export { default as Post } from './models/post';
export { default as Comment } from './models/comment';
export { default as testMongodb } from './test-mongodb';
export { default as Like } from './models/like';
5 changes: 4 additions & 1 deletion backend/src/mongo/models/comment.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import mongoose from 'mongoose';
import { Post } from '../index';

const commentSchema = new mongoose.Schema(
{
Expand Down Expand Up @@ -36,6 +35,8 @@ const commentSchema = new mongoose.Schema(

commentSchema.pre('remove', async function handleCommentDeletion(next) {
const thisComment = this;
const Post = this.model('Post');
const Like = this.model('Like');
const post = await Post.findById(thisComment.post);
const isThisCommentAReply = Boolean(thisComment.parentComment);
let deletedCommentsIds = [thisComment._id.toString()];
Expand Down Expand Up @@ -72,6 +73,8 @@ commentSchema.pre('remove', async function handleCommentDeletion(next) {

await post.save();

await Like.deleteMany({ 'likedEntity.id': { $in: deletedCommentsIds }, 'likedEntity.model': 'Comment' });

next();
});

Expand Down
14 changes: 14 additions & 0 deletions backend/src/mongo/models/image-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import mongoose from 'mongoose';

export default new mongoose.Schema(
{
url: {
type: String,
required: true,
},
publicId: {
type: String,
required: true,
},
},
);
33 changes: 33 additions & 0 deletions backend/src/mongo/models/like.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import mongoose from 'mongoose';

const likeSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'User',
},
likedEntity: {
id: {
type: mongoose.Schema.Types.ObjectId,
required: true,
refPath: 'model',
},
model: {
type: String,
required: true,
enum: ['Post', 'Comment'],
},
},
}, {
timestamps: true,
});

likeSchema.set('toJSON', {
transform: (_document, returnedObject) => {
returnedObject.id = returnedObject._id.toString();
delete returnedObject._id;
delete returnedObject.__v;
},
});

export default mongoose.model('Like', likeSchema);
35 changes: 14 additions & 21 deletions backend/src/mongo/models/post.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,5 @@
import mongoose from 'mongoose';
import { Comment } from '../index';

export const imageSchema = new mongoose.Schema(
{
url: {
type: String,
required: true,
},
publicId: {
type: String,
required: true,
},
},
);
import imageSchema from './image-schema';

const postSchema = new mongoose.Schema(
{
Expand All @@ -35,10 +22,6 @@ const postSchema = new mongoose.Schema(
ref: 'Comment',
},
],
likes: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
},
{ timestamps: true },
);
Expand All @@ -51,10 +34,20 @@ postSchema.set('toJSON', {
},
});

postSchema.pre('remove', async function deleteAllCommentsOfPost(next) {
const post = this;
postSchema.pre('remove', async function handlePostDeletion(next) {
const thisPost = this;
const Comment = this.model('Comment');
const Like = this.model('Like');
let postCommentIds = await Comment.find({ post: thisPost._id }).select('_id');

postCommentIds = postCommentIds.map(
(comment: { _id: mongoose.Schema.Types.ObjectId }) => comment._id.toString(),
);

await Comment.deleteMany({ post: thisPost._id });
await Like.deleteMany({ 'likedEntity.id': thisPost._id, 'likedEntity.model': 'Post' });
await Like.deleteMany({ 'likedEntity.id': { $in: postCommentIds }, 'likedEntity.model': 'Comment' });

await Comment.deleteMany({ post: post._id });
next();
});

Expand Down
2 changes: 1 addition & 1 deletion backend/src/mongo/models/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import mongoose from 'mongoose';
import { imageSchema } from './post';
import imageSchema from './image-schema';

const userSchema = new mongoose.Schema({
fullName: {
Expand Down
100 changes: 100 additions & 0 deletions backend/src/routes/likes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import express from 'express';
import { authenticator } from '../utils/middleware';
import logger from '../utils/logger';
import likeService from '../services/like-service';

const router = express.Router();

router.post('/', authenticator(), async (req, res, next) => {
const userId = req.userToken!.id;

try {
await likeService.addLike({
userId,
entityId: req.body.entityId,
entityModel: req.body.entityModel,
});

return res.status(201).end();
} catch (error) {
const errorMessage = logger.getErrorMessage(error);
logger.error(errorMessage);

if (/not found/i.test(errorMessage)) {
return res.status(404).send({ error: errorMessage });
}

if (/same entity twice/i.test(errorMessage)) {
return res.status(400).send({ error: errorMessage });
}

return next(error);
}
});

router.delete('/:entityId', authenticator(), async (req, res, next) => {
const userId = req.userToken!.id;
const { entityId } = req.params;

try {
await likeService.removeLikeByUserIdAndEntityId({
user: userId,
entityId,
});

return res.status(204).end();
} catch (error) {
const errorMessage = logger.getErrorMessage(error);
logger.error(errorMessage);

return next(error);
}
});

router.get('/:entityId/likeCount', async (req, res, next) => {
const { entityId } = req.params;

try {
const likeCount = await likeService.getLikeCountByEntityId(entityId);

return res.status(200).send({ likeCount });
} catch (error) {
const errorMessage = logger.getErrorMessage(error);
logger.error(errorMessage);

return next(error);
}
});

router.get('/:entityId/likes', async (req, res, next) => {
const { entityId } = req.params;

try {
const likes = await likeService.getLikeUsersByEntityId(entityId);

return res.status(200).send({ likes });
} catch (error) {
const errorMessage = logger.getErrorMessage(error);
logger.error(errorMessage);

return next(error);
}
});

router.get('/:entityId/hasLiked', authenticator(), async (req, res, next) => {
const userId = req.userToken!.id;
const { entityId } = req.params;

try {
const hasLiked = await likeService.hasUserLikedEntity(userId, entityId);

return res.status(200).send({ hasLiked });
} catch (error) {
const errorMessage = logger.getErrorMessage(error);
logger.error(errorMessage);

return next(error);
}
});

export default router;
77 changes: 77 additions & 0 deletions backend/src/services/like-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
Like, Post, Comment,
} from '../mongo';
import { NewLike } from '../types';

const addLike = async (newLikeFields: NewLike) => {
let entity;

if (newLikeFields.entityModel === 'Post') {
entity = await Post.findById(newLikeFields.entityId);
} else if (newLikeFields.entityModel === 'Comment') {
entity = await Comment.findById(newLikeFields.entityId);
}

if (!entity) {
throw new Error('Entity not found');
}

const existingLike = await Like.findOne({
'likedEntity.id': newLikeFields.entityId,
user: newLikeFields.userId,
});

if (existingLike) {
throw new Error('Can not like the same entity twice');
}

await Like.create({
user: newLikeFields.userId,
likedEntity: {
id: newLikeFields.entityId,
model: newLikeFields.entityModel,
},
});
};

const removeLikeByUserIdAndEntityId = async (
{ user, entityId } : { user: string, entityId: string, },
) => {
await Like.findOneAndDelete({
'likedEntity.id': entityId,
user,
});
};

const getLikeCountByEntityId = async (entityId: string) => {
const likeCount = await Like.countDocuments({
'likedEntity.id': entityId,
});

return likeCount;
};

const getLikeUsersByEntityId = async (entityId: string) => {
const likes = await Like.find({
'likedEntity.id': entityId,
}).populate('user', 'username');

return likes.map((like) => like.user);
};

const hasUserLikedEntity = async (userId: string, entityId: string) => {
const like = await Like.findOne({
'likedEntity.id': entityId,
user: userId,
});

return !!like;
};

export default {
addLike,
removeLikeByUserIdAndEntityId,
getLikeCountByEntityId,
getLikeUsersByEntityId,
hasUserLikedEntity,
};
6 changes: 6 additions & 0 deletions backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export interface NewComment {
parentComment?: string, // ref -> Comment (the root comment)
}

export interface NewLike {
userId: string, // ref -> User
entityId: string, // ref -> Post | Comment
entityModel: 'Post' | 'Comment',
}

export interface Post {
id: string,
creator: User, // ref -> User
Expand Down
Loading

0 comments on commit 108ab48

Please sign in to comment.