feat(backend): implement comments, image uploads, and data consistency

Implement additional backend features and improve data models:

Comments System:
- Create Comment model with user and post relationships
- Add comments routes: GET /api/posts/:postId/comments (paginated), POST (create), DELETE (own comments)
- Update Post model with commentsCount field
- Emit Socket.IO events for newComment and commentDeleted
- Pagination support for comment lists
- Authorization checks (users can only delete own comments)
- 500 character limit on comments

Image Upload System:
- Implement Cloudinary configuration (config/cloudinary.js)
- Add uploadImage() and deleteImage() helper functions
- Image optimization: max 1000x1000, auto quality, auto format (WebP)
- Integrate image upload in users routes (profile pictures)
- Integrate image upload in posts routes (post images with add/update endpoints)
- File validation: 5MB limit, JPG/PNG/GIF/WebP only
- Automatic image deletion when removing posts/reports

Data Consistency Improvements:
- Add cascade deletes in Street model (remove from user, delete associated tasks)
- Add cascade deletes in Task model (remove from user completedTasks)
- Add cascade deletes in Post model (remove from user posts)
- Update user relationships on save (adoptedStreets, completedTasks, posts, events)
- Add proper indexes for performance (2dsphere for location, compound indexes)
- Add virtual relationships and toJSON configurations

Model Updates:
- Street: Add cascade hooks, location 2dsphere index
- Task: Add cascade hooks, compound indexes for queries
- Post: Add imageUrl, cloudinaryPublicId, commentsCount fields
- Event: Add participants tracking
- Report: Add image upload support
- User: Add earnedBadges virtual, profilePicture, cloudinaryPublicId

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
William Valentin
2025-11-01 10:43:08 -07:00
parent e7396c10d6
commit 7c70a8d098
10 changed files with 548 additions and 40 deletions

146
backend/routes/comments.js Normal file
View File

@@ -0,0 +1,146 @@
const express = require("express");
const mongoose = require("mongoose");
const Comment = require("../models/Comment");
const Post = require("../models/Post");
const auth = require("../middleware/auth");
const { paginate, buildPaginatedResponse } = require("../middleware/pagination");
const { asyncHandler } = require("../middleware/errorHandler");
const router = express.Router();
/**
* GET /api/posts/:postId/comments
* Get all comments for a post (paginated)
*/
router.get(
"/:postId/comments",
paginate,
asyncHandler(async (req, res) => {
const { postId } = req.params;
const { skip, limit, page } = req.pagination;
// Verify post exists
const post = await Post.findById(postId);
if (!post) {
return res.status(404).json({ msg: "Post not found" });
}
// Get comments with pagination
const comments = await Comment.find({ post: postId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.populate("user", ["name", "profilePicture"]);
const totalCount = await Comment.countDocuments({ post: postId });
res.json(buildPaginatedResponse(comments, totalCount, page, limit));
})
);
/**
* POST /api/posts/:postId/comments
* Create a new comment on a post
*/
router.post(
"/:postId/comments",
auth,
asyncHandler(async (req, res) => {
const { postId } = req.params;
const { content } = req.body;
// Validate content
if (!content || content.trim().length === 0) {
return res.status(400).json({ msg: "Comment content is required" });
}
if (content.length > 500) {
return res
.status(400)
.json({ msg: "Comment content must be 500 characters or less" });
}
// Verify post exists
const post = await Post.findById(postId);
if (!post) {
return res.status(404).json({ msg: "Post not found" });
}
// Create comment
const newComment = new Comment({
user: req.user.id,
post: postId,
content: content.trim(),
});
const comment = await newComment.save();
// Update post's comment count
await Post.findByIdAndUpdate(postId, {
$inc: { commentsCount: 1 },
});
// Populate user data before sending response
await comment.populate("user", ["name", "profilePicture"]);
// Emit Socket.IO event for new comment
const io = req.app.get("io");
if (io) {
io.to(`post_${postId}`).emit("newComment", {
postId,
comment,
});
}
res.status(201).json(comment);
})
);
/**
* DELETE /api/posts/:postId/comments/:commentId
* Delete own comment
*/
router.delete(
"/:postId/comments/:commentId",
auth,
asyncHandler(async (req, res) => {
const { postId, commentId } = req.params;
// Find comment
const comment = await Comment.findById(commentId);
if (!comment) {
return res.status(404).json({ msg: "Comment not found" });
}
// Verify comment belongs to the post
if (comment.post.toString() !== postId) {
return res.status(400).json({ msg: "Comment does not belong to this post" });
}
// Verify user owns the comment
if (comment.user.toString() !== req.user.id) {
return res.status(403).json({ msg: "Not authorized to delete this comment" });
}
// Delete comment
await Comment.findByIdAndDelete(commentId);
// Update post's comment count
await Post.findByIdAndUpdate(postId, {
$inc: { commentsCount: -1 },
});
// Emit Socket.IO event for deleted comment
const io = req.app.get("io");
if (io) {
io.to(`post_${postId}`).emit("commentDeleted", {
postId,
commentId,
});
}
res.json({ msg: "Comment deleted successfully" });
})
);
module.exports = router;

View File

@@ -1,44 +1,126 @@
const express = require("express");
const Report = require("../models/Report");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const {
createReportValidation,
reportIdValidation,
} = require("../middleware/validators/reportValidator");
const { upload, handleUploadError } = require("../middleware/upload");
const { uploadImage, deleteImage } = require("../config/cloudinary");
const router = express.Router();
// Get all reports
router.get("/", async (req, res) => {
try {
// Get all reports (with pagination)
router.get(
"/",
asyncHandler(async (req, res) => {
const { paginate, buildPaginatedResponse } = require("../middleware/pagination");
// Parse pagination params
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 100);
const skip = (page - 1) * limit;
const reports = await Report.find()
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.populate("street", ["name"])
.populate("user", ["name"]);
res.json(reports);
} catch (err) {
console.error(err.message);
res.status(500).send("Server error");
}
});
.populate("user", ["name", "profilePicture"]);
// Create a report
router.post("/", auth, async (req, res) => {
const { street, issue } = req.body;
const totalCount = await Report.countDocuments();
try {
const newReport = new Report({
res.json(buildPaginatedResponse(reports, totalCount, page, limit));
}),
);
// Create a report with optional image
router.post(
"/",
auth,
upload.single("image"),
handleUploadError,
createReportValidation,
asyncHandler(async (req, res) => {
const { street, issue } = req.body;
const reportData = {
street,
user: req.user.id,
issue,
});
};
// Upload image if provided
if (req.file) {
const result = await uploadImage(
req.file.buffer,
"adopt-a-street/reports",
);
reportData.imageUrl = result.url;
reportData.cloudinaryPublicId = result.publicId;
}
const newReport = new Report(reportData);
const report = await newReport.save();
// Populate user and street data
await report.populate([
{ path: "user", select: "name profilePicture" },
{ path: "street", select: "name" },
]);
res.json(report);
} catch (err) {
console.error(err.message);
res.status(500).send("Server error");
}
});
}),
);
// Add image to existing report
router.post(
"/:id/image",
auth,
upload.single("image"),
handleUploadError,
reportIdValidation,
asyncHandler(async (req, res) => {
const report = await Report.findById(req.params.id);
if (!report) {
return res.status(404).json({ msg: "Report not found" });
}
// Verify user owns the report
if (report.user.toString() !== req.user.id) {
return res.status(403).json({ msg: "Not authorized" });
}
if (!req.file) {
return res.status(400).json({ msg: "No image file provided" });
}
// Delete old image if exists
if (report.cloudinaryPublicId) {
await deleteImage(report.cloudinaryPublicId);
}
// Upload new image
const result = await uploadImage(
req.file.buffer,
"adopt-a-street/reports",
);
report.imageUrl = result.url;
report.cloudinaryPublicId = result.publicId;
await report.save();
res.json(report);
}),
);
// Resolve a report
router.put("/:id", auth, async (req, res) => {
try {
router.put(
"/:id",
auth,
reportIdValidation,
asyncHandler(async (req, res) => {
const report = await Report.findById(req.params.id);
if (!report) {
return res.status(404).json({ msg: "Report not found" });
@@ -49,10 +131,7 @@ router.put("/:id", auth, async (req, res) => {
await report.save();
res.json(report);
} catch (err) {
console.error(err.message);
res.status(500).send("Server error");
}
});
}),
);
module.exports = router;

View File

@@ -1,21 +1,90 @@
const express = require('express');
const User = require('../models/User');
const auth = require('../middleware/auth');
const express = require("express");
const User = require("../models/User");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { userIdValidation } = require("../middleware/validators/userValidator");
const { upload, handleUploadError } = require("../middleware/upload");
const { uploadImage, deleteImage } = require("../config/cloudinary");
const router = express.Router();
// Get user by ID
router.get('/:id', auth, async (req, res) => {
try {
const user = await User.findById(req.params.id).populate('adoptedStreets');
router.get(
"/:id",
auth,
userIdValidation,
asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id).populate("adoptedStreets");
if (!user) {
return res.status(404).json({ msg: 'User not found' });
return res.status(404).json({ msg: "User not found" });
}
res.json(user);
} catch (err) {
console.error(err.message);
res.status(500).send('Server error');
}
});
}),
);
// Upload profile picture
router.post(
"/profile-picture",
auth,
upload.single("image"),
handleUploadError,
asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ msg: "No image file provided" });
}
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
// Delete old profile picture if exists
if (user.cloudinaryPublicId) {
await deleteImage(user.cloudinaryPublicId);
}
// Upload new image to Cloudinary
const result = await uploadImage(
req.file.buffer,
"adopt-a-street/profiles",
);
// Update user with new profile picture
user.profilePicture = result.url;
user.cloudinaryPublicId = result.publicId;
await user.save();
res.json({
msg: "Profile picture updated successfully",
profilePicture: user.profilePicture,
});
}),
);
// Delete profile picture
router.delete(
"/profile-picture",
auth,
asyncHandler(async (req, res) => {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (!user.cloudinaryPublicId) {
return res.status(400).json({ msg: "No profile picture to delete" });
}
// Delete image from Cloudinary
await deleteImage(user.cloudinaryPublicId);
// Remove from user
user.profilePicture = undefined;
user.cloudinaryPublicId = undefined;
await user.save();
res.json({ msg: "Profile picture deleted successfully" });
}),
);
module.exports = router;