Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -401,16 +401,29 @@ model Comment {
userId String @db.Uuid
postId String?
content String
createdAt DateTime @default(now())
createdAt DateTime @default(now())
parentId String?
parent_comment Comment? @relation("CommentToComment", fields: [parentId], references: [id], onDelete: SetNull)
child_comment Comment[] @relation("CommentToComment")
Post Post? @relation(fields: [postId], references: [id])
UserProfile UserProfile @relation(fields: [userId], references: [userId])
parent_comment Comment? @relation("CommentToComment", fields: [parentId], references: [id], onDelete: SetNull)
child_comment Comment[] @relation("CommentToComment")
Post Post? @relation(fields: [postId], references: [id])
UserProfile UserProfile @relation(fields: [userId], references: [userId])
CommentLike CommentLike[]

@@schema("public")
}

model CommentLike {
id String @id @default(uuid())
commentId String
userId String @db.Uuid
createdAt DateTime @default(now())
Comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
UserProfile UserProfile @relation(fields: [userId], references: [userId])

@@unique([commentId, userId])
@@schema("public")
}


// Post model supports both SHORT and LONG posts about movies
// SHORT posts: <=280 chars, no stars
Expand Down Expand Up @@ -492,6 +505,7 @@ model UserProfile {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Comment Comment[]
CommentLike CommentLike[]
Post Post[]
PostReaction PostReaction[]
UserFollow_UserFollow_followerIdToUserProfile UserFollow[] @relation("UserFollow_followerIdToUserProfile")
Expand Down
171 changes: 169 additions & 2 deletions backend/src/controllers/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const getComment = async (req: AuthenticatedRequest, res: Response) => {
export const getCommentsTree = async (req: AuthenticatedRequest, res: Response) => {
const timestamp = new Date().toISOString();
const { postId, ratingId } = req.params;
const userId = req.user?.id;

if (!postId && !ratingId) {
return res.status(400).json({
Expand All @@ -82,12 +83,27 @@ export const getCommentsTree = async (req: AuthenticatedRequest, res: Response)
where: postId ? { postId } : { ratingId },
orderBy: { createdAt: 'asc' },
include: {
UserProfile: { select: { userId: true, username: true, profilePicture: true } }
UserProfile: { select: { userId: true, username: true, profilePicture: true } },
CommentLike: true,
}
});

// Transform to include like count and whether current user liked
const commentsWithLikes = comments.map((comment) => ({
id: comment.id,
userId: comment.userId,
ratingId: comment.ratingId,
postId: comment.postId,
parentId: comment.parentId,
content: comment.content,
createdAt: comment.createdAt,
UserProfile: comment.UserProfile,
likeCount: comment.CommentLike.length,
liked: userId ? comment.CommentLike.some((like) => like.userId === userId) : false,
}));

// Return flat list - client builds tree
res.json({ message: "Comments retrieved", comments });
res.json({ message: "Comments retrieved", comments: commentsWithLikes });
} catch (error) {
console.error(`[${timestamp}] getCommentsTree error:`, error);
res.status(500).json({
Expand Down Expand Up @@ -270,6 +286,157 @@ export const deleteComment = async (req: AuthenticatedRequest, res: Response) =>
}
};

/**
* POST /api/comment/:id/like
* Toggles a like on a comment for the authenticated user
*/
export const toggleCommentLike = async (req: AuthenticatedRequest, res: Response) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] toggleCommentLike called by user: ${req.user?.id || "unknown"}`);

if (!req.user) {
return res.status(401).json({
message: "User not authenticated",
timestamp,
endpoint: "/api/comment/:id/like",
});
}

const { id: commentId } = req.params;

if (!commentId) {
return res.status(400).json({
message: "Missing comment ID",
timestamp,
});
}

try {
// Check if comment exists
const comment = await prisma.comment.findUnique({
where: { id: commentId },
});

if (!comment) {
return res.status(404).json({ message: "Comment not found", timestamp });
}

// Check if user already liked this comment
const existingLike = await prisma.commentLike.findUnique({
where: {
commentId_userId: {
commentId,
userId: req.user.id,
},
},
});

if (existingLike) {
// Unlike - remove the like
await prisma.commentLike.delete({
where: { id: existingLike.id },
});

const likeCount = await prisma.commentLike.count({
where: { commentId },
});

return res.json({
message: "Comment unliked successfully",
liked: false,
likeCount,
timestamp,
});
} else {
// Like - add a new like
await prisma.commentLike.create({
data: {
commentId,
userId: req.user.id,
},
});

const likeCount = await prisma.commentLike.count({
where: { commentId },
});

return res.json({
message: "Comment liked successfully",
liked: true,
likeCount,
timestamp,
});
}
} catch (error) {
console.error(`[${timestamp}] toggleCommentLike error:`, error);
res.status(500).json({
message: "Internal server error toggling comment like",
timestamp,
});
}
};

/**
* GET /api/comment/:id/likes
* Returns the like count and whether the current user has liked the comment
*/
export const getCommentLikes = async (req: AuthenticatedRequest, res: Response) => {
const timestamp = new Date().toISOString();

if (!req.user) {
return res.status(401).json({
message: "User not authenticated",
timestamp,
endpoint: "/api/comment/:id/likes",
});
}

const { id: commentId } = req.params;

if (!commentId) {
return res.status(400).json({
message: "Missing comment ID",
timestamp,
});
}

try {
const comment = await prisma.comment.findUnique({
where: { id: commentId },
});

if (!comment) {
return res.status(404).json({ message: "Comment not found", timestamp });
}

const likeCount = await prisma.commentLike.count({
where: { commentId },
});

const userLike = await prisma.commentLike.findUnique({
where: {
commentId_userId: {
commentId,
userId: req.user.id,
},
},
});

res.json({
message: "Comment likes retrieved successfully",
likeCount,
liked: !!userLike,
timestamp,
});
} catch (error) {
console.error(`[${timestamp}] getCommentLikes error:`, error);
res.status(500).json({
message: "Internal server error retrieving comment likes",
timestamp,
});
}
};

// backend/src/controllers/comment.ts
export async function getMovieComments(req: Request, res: Response) {
try {
Expand Down
4 changes: 3 additions & 1 deletion backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { protect } from "../controllers/protected";
import { getLocalEvent, createLocalEvent, updateLocalEvent, deleteLocalEvent, getLocalEvents } from "../controllers/local-events"
import { createOrUpdateRsvp, getUserRsvp, deleteRsvp, getEventAttendees } from "../controllers/event-rsvp"
import { followUser, unfollowUser, getFollowers, getFollowing } from "../controllers/userFollows";
import { getComment, createComment, updateComment, deleteComment, getMovieComments, getCommentsTree} from "../controllers/comment"
import { getComment, createComment, updateComment, deleteComment, getMovieComments, getCommentsTree, toggleCommentLike, getCommentLikes } from "../controllers/comment"
import { createRating, getRatings, getRatingById, deleteRating, updateRating,getMovieRatings } from "../controllers/ratings";
import { getAllMovies } from "../controllers/movies";
import { createPost, getPostById, getPosts, updatePost, deletePost, getPostReposts, toggleReaction, getPostReactions } from "../controllers/post.js";
Expand Down Expand Up @@ -66,6 +66,8 @@ router.post("/api/comment", createComment);
router.get("/api/comment/:id", getComment)
router.put("/api/comment/:id", updateComment);
router.delete("/api/comment/:id", deleteComment);
router.post("/api/comment/:id/like", toggleCommentLike);
router.get("/api/comment/:id/likes", getCommentLikes);
router.get("/api/:movieId/comments", getMovieComments);
router.get("/api/comments/post/:postId", getCommentsTree);
router.get("/api/comments/rating/:ratingId", getCommentsTree);
Expand Down
65 changes: 58 additions & 7 deletions backend/src/tests/api/comment.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jest.mock("../../services/db", () => {
const ratings = new Map<string, any>();
const posts = new Map<string, any>();
const comments = new Map<string, any>();
const commentLikes = new Map<string, any>();

const clone = (record: any) => (record ? { ...record } : record);

Expand Down Expand Up @@ -164,6 +165,42 @@ jest.mock("../../services/db", () => {
}
};

const commentLikeModel = {
create: jest.fn(async ({ data }: any) => {
const id = data.id ?? crypto.randomUUID();
const record = { ...data, id };
commentLikes.set(id, record);
return clone(record);
}),
findUnique: jest.fn(async ({ where }: any) => {
if (where.commentId_userId) {
const { commentId, userId } = where.commentId_userId;
const like = Array.from(commentLikes.values()).find(
(l) => l.commentId === commentId && l.userId === userId
);
return like ? clone(like) : null;
}
if (where.id) {
return clone(commentLikes.get(where.id) ?? null);
}
return null;
}),
delete: jest.fn(async ({ where: { id } }: any) => {
const record = commentLikes.get(id);
ensureRecordExists(record);
commentLikes.delete(id);
return clone(record);
}),
count: jest.fn(async ({ where }: any = {}) => {
if (where?.commentId) {
return Array.from(commentLikes.values()).filter(
(like) => like.commentId === where.commentId
).length;
}
return commentLikes.size;
}),
};

const commentModel = {
create: jest.fn(async ({ data }: any) => {
const id = data.id ?? crypto.randomUUID();
Expand Down Expand Up @@ -197,10 +234,12 @@ jest.mock("../../services/db", () => {
);
}

// Handle includes (UserProfile)
if (include?.UserProfile) {
const selectFields = include.UserProfile.select;
results = results.map((record) => {
// Handle includes (UserProfile and CommentLike)
results = results.map((record) => {
const result = { ...record };

if (include?.UserProfile) {
const selectFields = include.UserProfile.select;
const userProfile = userProfiles.get(record.userId);
const profileData: any = {};
if (selectFields) {
Expand All @@ -210,9 +249,19 @@ jest.mock("../../services/db", () => {
}
}
}
return { ...record, UserProfile: userProfile ? profileData : null };
});
}
result.UserProfile = userProfile ? profileData : null;
}

if (include?.CommentLike) {
// Return all likes for this comment
const likes = Array.from(commentLikes.values()).filter(
(like) => like.commentId === record.id
);
result.CommentLike = likes;
}

return result;
});

return results.map(clone);
}),
Expand Down Expand Up @@ -249,11 +298,13 @@ jest.mock("../../services/db", () => {
rating: ratingModel,
post: postModel,
comment: commentModel,
commentLike: commentLikeModel,
$disconnect: jest.fn(async () => {
userProfiles.clear();
ratings.clear();
posts.clear();
comments.clear();
commentLikes.clear();
}),
},
};
Expand Down
16 changes: 16 additions & 0 deletions frontend/app/commentSection/_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type ApiComment = {
id: string;
userId: string;
ratingId?: string | null;
postId?: string | null;
parentId?: string | null;
content: string;
createdAt: string;
likeCount: number;
liked: boolean;
UserProfile?: {
userId: string;
username: string | null;
profilePicture: string | null;
} | null;
};
Loading