Overview
This lesson covers the essential controller implementations for all the remaining features of our YouTube-like backend. Each controller follows the same pattern: validate inputs → check authorization → perform DB operation → return ApiResponse.
---
Comment Controllers
addComment:
const addComment = asyncHandler(async (req, res) => {
const { videoId } = req.params;
const { content } = req.body;
if (!content?.trim()) throw new ApiError(400, 'Comment content is required');
if (!mongoose.isValidObjectId(videoId)) throw new ApiError(400, 'Invalid video ID');
const video = await Video.findById(videoId);
if (!video) throw new ApiError(404, 'Video not found');
const comment = await Comment.create({ content: content.trim(), video: videoId, owner: req.user._id });
return res.status(201).json(new ApiResponse(201, comment, 'Comment added'));
});
updateComment and deleteComment — always check ownership before allowing update/delete:
const deleteComment = asyncHandler(async (req, res) => {
const comment = await Comment.findById(req.params.commentId);
if (!comment) throw new ApiError(404, 'Comment not found');
if (comment.owner.toString() !== req.user._id.toString()) {
throw new ApiError(403, 'Unauthorized to delete this comment');
}
await Comment.findByIdAndDelete(req.params.commentId);
// Also delete all likes on this comment
await Like.deleteMany({ comment: req.params.commentId });
return res.status(200).json(new ApiResponse(200, {}, 'Comment deleted'));
});
getVideoComments with pagination and like info:
const getVideoComments = asyncHandler(async (req, res) => {
const { videoId } = req.params;
const { page = 1, limit = 10 } = req.query;
const skip = (Number(page) - 1) * Number(limit);
const comments = await Comment.aggregate([
{ $match: { video: new mongoose.Types.ObjectId(videoId) } },
{ $sort: { createdAt: -1 } },
{ $skip: skip },
{ $limit: Number(limit) },
{
$lookup: { from: 'users', localField: 'owner', foreignField: '_id', as: 'owner',
pipeline: [{ $project: { fullName: 1, username: 1, avatar: 1 } }],
},
},
{
$lookup: { from: 'likes', localField: '_id', foreignField: 'comment', as: 'likes' },
},
{
$addFields: {
owner: { $first: '$owner' },
likesCount: { $size: '$likes' },
isLiked: { $in: [new mongoose.Types.ObjectId(req.user._id), '$likes.likedBy'] },
},
},
{ $project: { content: 1, owner: 1, likesCount: 1, isLiked: 1, createdAt: 1 } },
]);
const total = await Comment.countDocuments({ video: videoId });
return res.status(200).json(new ApiResponse(200, { comments, total, page, limit }, 'Comments fetched'));
});
---
Like Controllers
toggleVideoLike:
const toggleVideoLike = asyncHandler(async (req, res) => {
const { videoId } = req.params;
const existing = await Like.findOne({ likedBy: req.user._id, video: videoId });
if (existing) {
await Like.findByIdAndDelete(existing._id);
return res.status(200).json(new ApiResponse(200, { isLiked: false }, 'Like removed'));
}
await Like.create({ likedBy: req.user._id, video: videoId });
return res.status(200).json(new ApiResponse(200, { isLiked: true }, 'Video liked'));
});
getLikedVideos:
const getLikedVideos = asyncHandler(async (req, res) => {
const likes = await Like.find({ likedBy: req.user._id, video: { $exists: true } })
.populate({ path: 'video', select: 'title thumbnail duration views owner', populate: { path: 'owner', select: 'username avatar' } })
.sort({ createdAt: -1 });
const videos = likes.map((l) => l.video).filter(Boolean);
return res.status(200).json(new ApiResponse(200, videos, 'Liked videos fetched'));
});
---
Playlist Controllers
const addVideoToPlaylist = asyncHandler(async (req, res) => {
const { playlistId, videoId } = req.params;
const playlist = await Playlist.findById(playlistId);
if (!playlist) throw new ApiError(404, 'Playlist not found');
if (playlist.owner.toString() !== req.user._id.toString())
throw new ApiError(403, 'Unauthorized');
const video = await Video.findById(videoId);
if (!video) throw new ApiError(404, 'Video not found');
const updated = await Playlist.findByIdAndUpdate(
playlistId,
{ $addToSet: { videos: videoId } }, // $addToSet prevents duplicates
{ new: true }
);
return res.status(200).json(new ApiResponse(200, updated, 'Video added to playlist'));
});
---
Video Controllers — Pagination Pattern
getAllVideos with search, sort, and pagination:
const getAllVideos = asyncHandler(async (req, res) => {
const { page = 1, limit = 10, query = '', sortBy = 'createdAt', sortType = 'desc', userId } = req.query;
const skip = (Number(page) - 1) * Number(limit);
const matchStage = { isPublished: true };
if (query) matchStage['$text'] = { $search: query };
if (userId && mongoose.isValidObjectId(userId)) matchStage['owner'] = new mongoose.Types.ObjectId(userId);
const videos = await Video.aggregate([
{ $match: matchStage },
{ $lookup: { from: 'users', localField: 'owner', foreignField: '_id', as: 'owner',
pipeline: [{ $project: { fullName: 1, username: 1, avatar: 1 } }] } },
{ $addFields: { owner: { $first: '$owner' } } },
{ $sort: { [sortBy]: sortType === 'asc' ? 1 : -1 } },
{ $skip: skip },
{ $limit: Number(limit) },
{ $project: { title: 1, thumbnail: 1, duration: 1, views: 1, owner: 1, createdAt: 1 } },
]);
const total = await Video.countDocuments(matchStage);
return res.status(200).json(new ApiResponse(200, { videos, total, page, limit }, 'Videos fetched'));
});
---
Key Controller Patterns Summary
| Pattern | Implementation |
|---|---|
| Ownership check | doc.owner.toString() !== req.user._id.toString() |
| Pagination | skip = (page-1) * limit, $skip + $limit in aggregation |
| Toggle | findOne → delete if exists, create if not |
| Prevent duplicates | $addToSet in $set update |
| Remove from array | $pull: { array: value } |
| Cascade delete | Delete related documents (likes on deleted comment) |
| Validate ObjectId | mongoose.isValidObjectId(id) before DB queries |