Here we are going to talk about how we can impement this feature for FindMyBMW, combining React for the frontend and Spring Boot for the backend.
We wanted to create a commenting system where users could engage in discussions about BMW models. The key requirements were:
1. Users should be able to view all comments on a review
2. Logged-in users should be able to post new comments
3. Comments should display the author's username and timestamp
4. The system should be secure and performant
Let's take a look into an interesting aspect of our comment system - how we handle date formatting to create a more user-friendly experience similar to how Youtube comments are implemented. This feature makes comments feel more natural by showing relative time for recent comments and switching to standard dates for older ones. For instance, dates that are over 1 day old, we simply display the date in yyyy-mm-dd format. If the comment was within a single day and over 1 hour, we display on how many hours ago it was posted. When the comment is under 1 hour and over 1 minute, we display on how many minutes ago it was posted. Lastly, if the comment was under 1 minute, we display as 'just now'.
In our FullReview
component, we implemented a sophisticated date formatting function that adapts how timestamps are displayed based on how recent they are.
const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return 'just now';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
} else {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
};
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
This conversion is crucial because it allows us to perform time-based calculations. The Math.floor function ensures we work with whole seconds, making our comparisons more reliable.
For very recent comments (less than a minute old):
if (diffInSeconds < 60) {
return 'just now';
}
This creates an immediate sense of activity in the comments section. When someone has just posted a comment, other users see it as "just now" rather than "0 minutes ago," which feels more natural.
For comments less than an hour old:
else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
}
Notice the handling of plural forms using a ternary operator. This ensures grammatically correct phrases like "1 minute ago" versus "2 minutes ago."
For comments less than a day old:
else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
The same plural handling is applied to hours, maintaining consistency in our time formatting.
For older comments:
else {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
Here, we format dates using padStart(2, '0') to ensure consistent two-digit formatting for months and days. This means January 5th appears as "01-05" rather than "1-5", making the dates more readable and consistent.
The function is used in our comment rendering:
{comments.map((comment, index) => (
<li key={index}>
<strong>{comment.username}</strong>: {comment.content} - {formatDate(comment.createdAt)}
</li>
))}
This implementation creates a dynamic and engaging comment section where users can easily understand when comments were posted. Recent comments show relative times ("just now", "5 minutes ago", "2 hours ago"), while older comments show calendar dates. This approach makes the comment section feel more alive and interactive while maintaining clarity for older comments.
It's worth noting that this implementation assumes all dates are in the same timezone. In a more complex application, you might want to add timezone handling to ensure dates are displayed correctly for users in different regions.
This date formatting system contributes significantly to the user experience of our comment system, making temporal relationships between comments more intuitive and easier to understand at a glance.
The key to our frontend comment system lies in the state management within our FullReview.js
component. We use React's useState hook to maintain two crucial pieces of state: comments
for storing the array of all comments, and commentText
for managing the current input in the comment form. The code looks like this:
const [comments, setComments] = useState([]);
const [commentText, setCommentText] = useState('');
This state setup is fundamental to our component's reactivity. When either of these states changes, React automatically re-renders the relevant parts of our component, ensuring the UI stays in sync with our data. The comments array holds all the comments for a particular review, while commentText tracks what the user is currently typing in the comment input field.
useEffect(() => {
const fetchComments = async () => {
try {
const response = await api.get(`/comments/post/${postId}`);
console.log(response)
setComments(response.data);
} catch (error) {
console.error('Error fetching comments', error);
}
};
fetchComments();
}, [postId]);
This code uses useEffect hook runs when the component mounts and whenever postId changes, thanks to the dependency array [postId]. Inside the effect, we define an async function that makes an API call to fetch comments for the current review. When the comments are retrieved, we update our comments state with setComments, which triggers a re-render with the new data.
The comment submission logic handles both the API interaction and UI update:
const handleCommentSubmit = async (event) => {
event.preventDefault();
try {
const token = localStorage.getItem('token');
const response = await api.post('/comments', {
postId: postId,
content: commentText
}, {
headers: {
'Authorization': `Bearer ${token}`
}
});
setComments([...comments, response.data]);
setCommentText('');
} catch (error) {
console.error('Error submitting comment', error);
}
};
This submission handler does several things. First, it prevents the default form submission behavior with event.preventDefault(). Then, it retrieves the authentication token from localStorage, which is crucial for our security implementation. The API call includes this token in its headers, along with the comment content and postId in the request body. After a successful submission, we use the spread operator (...comments) to create a new array with all existing comments plus the new one, providing an immediate UI update. Finally, we clear the comment input field by setting commentText to an empty string.
Here we use foreign keys to create meaningful relationships between different tables in our relational database (MySQL).
In our system, the 'comments' table serves as a bridge between 'users' and 'reviews', creating what we call a many-to-many relationship.
@Entity
@Table(name = "comments")
@Data
public class Comments {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Integer id;
@Column(name = "post_id")
private Integer postId; // References reviews table
@Column(name = "user_id")
private Integer userId; // References users table
@Column(name = "content")
private String content;
@Column(name = "created_at")
private Date createdAt;
}
This entity class shows us 2 important foreign key relationships. Think of these like connecting threads that tie different pieces of our app together. The post_id
connects each comment to a specific review, while the user_id
connects it to the person who wrote the comment.
@Entity
@Table(name = "reviews")
@Data
public class Reviews {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id") // This is referenced by comments.post_id
private Integer postId;
// Other review fields...
}
@Entity
@Table(name = "users")
@Data
public class Users {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id") // This is referenced by comments.user_id
private Integer id;
// Other user fields...
}
Let's take a look with a real-world analogy. Imagine you're organizing a book club discussion. Each comment in your book club discussion (comments table) needs two pieces of information:
1. Which book is being discussed (the 'post_id' referencing the 'reviews' table)
2. Who made the comment (the 'user_id' referencing the 'users' table)
In DB terms:
This is reflected in our service layer code:
@Service
public class CommentsService {
@Autowired
private CommentsRepository commentsRepository;
@Autowired
private UsersRepository usersRepository;
public String getUsernameById(Integer userId) {
// This method uses the user_id foreign key to fetch the username
return usersRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"))
.getUsername();
}
public List<Comments> getCommentsByPostId(Integer postId) {
// This method uses the post_id foreign key to fetch all comments for a review
return commentsRepository.findByPostId(postId);
}
}
When we create a new comment, these relationships come into play:
@PostMapping
public ResponseEntity<CommentResponse> createComment(
@RequestHeader("Authorization") String token,
@RequestBody Comments commentDetails) {
// Extract user information from token
String username = jwtTokenUtil.extractUsername(token);
Integer userId = commentsService.getUserIdByUsername(username);
Comments comment = new Comments();
comment.setUserId(userId); // Set the user foreign key
comment.setPostId(commentDetails.getPostId()); // Set the review foreign key
comment.setContent(commentDetails.getContent());
comment.setCreatedAt(new Date());
Comments createdComment = commentsService.createComment(comment);
// ...
}
This design gives us several advantages:
Data Integrity: We can't have orphaned comments. Every comment must be associated with both a valid user and a valid review.
Efficient Queries: We can easily find:
Relationship Maintenance: If a user is deleted, we can easily find and handle all their comments. Similarly, if a review is deleted, we can manage all associated comments.
When we display comments in our frontend, we use these relationships to show meaningful information:
const fetchComments = async () => {
try {
const response = await api.get(`/comments/post/${postId}`);
setComments(response.data); // Each comment includes username through the user_id relationship
} catch (error) {
console.error('Error fetching comments', error);
}
};
Understanding these relationships is crucial for maintaining data integrity and building features like comment filtering, user comment history, and review engagement metrics. Each foreign key serves as a crucial link in our data structure, enabling us to build a robust and maintainable comment system.
On the backend, our implementation starts with a data model. The Comments entity class is a perfect example of how we use JPA annotations to map our Java objects to database tables:
@Entity
@Table(name = "comments")
@Data
public class Comments {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Integer id;
@Column(name = "created_at")
private Date createdAt;
@Column(name = "post_id")
private Integer postId;
@Column(name = "user_id")
private Integer userId;
@Column(name = "content")
private String content;
@PrePersist
protected void onCreate() {
createdAt = new Date();
}
}
The @PrePersist annotation ensures that createdAt is automatically set when a new comment is created. The @Column annotations explicitly map each field to its corresponding database column, making the code more maintainable.
The CommentsController class handles our HTTP endpoints with clean, well-structured code:
@RestController
@RequestMapping("/api/comments")
public class CommentsController {
@Autowired
private CommentsService commentsService;
@GetMapping("/post/{postId}")
public List<CommentResponse> getCommentsByPostId(@PathVariable Integer postId) {
List<Comments> comments = commentsService.getCommentsByPostId(postId);
return comments.stream()
.map(comment -> new CommentResponse(
comment.getId(),
comment.getContent(),
comment.getCreatedAt(),
commentsService.getUsernameById(comment.getUserId())
)).collect(Collectors.toList());
}
}
This controller code showcases several important patterns. The @RestController and @RequestMapping annotations set up our REST endpoints efficiently. The getCommentsByPostId method demonstrates effective use of Java streams to transform our database entities into response DTOs. The method takes a postId as a path variable and returns a list of comments, each transformed into a CommentResponse object that includes the username instead of just the userId, making it more useful for the frontend.
The service layer contains our logic and handles data access through our repository:
@Service
public class CommentsService {
@Autowired
private CommentsRepository commentsRepository;
@Autowired
private UsersRepository usersRepository;
public List<Comments> getCommentsByPostId(Integer postId) {
return commentsRepository.findByPostId(postId);
}
public Comments createComment(Comments comment) {
return commentsRepository.save(comment);
}
public String getUsernameById(Integer userId) {
return usersRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"))
.getUsername();
}
}
This service layer code is a good example of separation of concerns. Each method has a single responsibility, whether it's fetching comments, creating a new comment, or retrieving a username. The use of repository injection through @Autowired demonstrates good dependency injection practices, making our code more testable and maintainable.
The combination of these components creates a robust, secure, and efficient comment system that provides a great user experience while maintaining clean, maintainable code.
One aspect I'm particularly proud of is our security implementation. We used JWT tokens for authentication, ensuring that only logged-in users can post comments. The token is sent with each request and validated on the server side:
String jwtToken = token.substring(7); // Remove "Bearer " prefix
String username = jwtTokenUtil.extractUsername(jwtToken);
Integer userId = commentsService.getUserIdByUsername(username);
What makes our comment system feel so responsive is how we handle state updates. When a user posts a new comment, we don't wait for a page refresh. Instead, we immediately update the UI while the server handles the persistence:
setComments([...comments, response.data]);
setCommentText('');
This creates a seamless experience where users can engage in discussions without any noticeable delay.
Building this comment system taught me several valuable lessons:
1. Always handle authentication tokens securely
2. Use optimistic updates for better user experience
3. Structure your data models carefully to support future features
4. Keep the API design clean and intuitive