Merge pull request #8 from sBubshait/feature/post-comments

Comments and Replies to Comments in Backend
This commit is contained in:
Saleh Bubshait 2025-07-27 10:48:10 +03:00 committed by GitHub
commit 6ebf8f4f2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 589 additions and 29 deletions

View File

@ -1,11 +1,9 @@
package online.wesal.wesal.config;
import online.wesal.wesal.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@ -24,9 +22,6 @@ import java.util.Arrays;
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@ -35,13 +30,6 @@ public class SecurityConfig {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
@ -71,7 +59,6 @@ public class SecurityConfig {
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();

View File

@ -0,0 +1,88 @@
package online.wesal.wesal.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import online.wesal.wesal.dto.ApiResponse;
import online.wesal.wesal.dto.CommentCreateRequestDTO;
import online.wesal.wesal.dto.CommentResponseDTO;
import java.util.List;
import online.wesal.wesal.service.CommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/posts/comments")
@CrossOrigin(origins = "*")
@Tag(name = "Comments", description = "Comment management endpoints")
public class CommentController {
@Autowired
private CommentService commentService;
@PostMapping(value = "/create", consumes = "application/json", produces = "application/json")
@Operation(summary = "Create comment", description = "Create a new comment or reply to an existing comment with 3-level nesting")
public ResponseEntity<ApiResponse<CommentResponseDTO>> createComment(
@Valid @RequestBody CommentCreateRequestDTO request,
BindingResult bindingResult,
Authentication authentication) {
if (bindingResult.hasErrors()) {
String errorMessage = bindingResult.getFieldErrors().stream()
.map(error -> {
String field = error.getField();
if ("postId".equals(field)) return "Valid post ID is required";
if ("body".equals(field)) return "Comment body is required and cannot exceed 1000 characters";
return error.getDefaultMessage();
})
.findFirst()
.orElse("Invalid input");
return ResponseEntity.badRequest().body(ApiResponse.error(errorMessage));
}
try {
CommentResponseDTO response = commentService.createComment(
request.getPostId(),
request.getBody(),
request.getReplyComment()
);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (RuntimeException e) {
String message;
if (e.getMessage().contains("Post not found")) {
message = "Post not found";
} else if (e.getMessage().contains("Parent comment not found")) {
message = "Comment you're replying to not found";
} else if (e.getMessage().contains("User not found") || e.getMessage().contains("creator not found")) {
message = "Authentication error. Please log in again.";
} else {
message = "Something went wrong.. We're sorry but try again later";
}
return ResponseEntity.badRequest().body(ApiResponse.error(message));
} catch (Exception e) {
return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later"));
}
}
@GetMapping("/list")
@Operation(summary = "Get comments for post", description = "Get all comments for a specific post in hierarchical structure with nested replies")
public ResponseEntity<ApiResponse<List<CommentResponseDTO>>> getCommentsByPostId(
@RequestParam Long postId,
Authentication authentication) {
try {
if (postId == null || postId <= 0) {
return ResponseEntity.badRequest().body(ApiResponse.error("Valid post ID is required"));
}
List<CommentResponseDTO> response = commentService.getCommentsByPostId(postId);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later"));
}
}
}

View File

@ -0,0 +1,54 @@
package online.wesal.wesal.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class CommentCreateRequestDTO {
@NotNull(message = "Post ID is required")
private Long postId;
@NotBlank(message = "Comment body is required")
@Size(max = 1000, message = "Comment body cannot exceed 1000 characters")
private String body;
private Long replyComment;
public CommentCreateRequestDTO() {}
public CommentCreateRequestDTO(Long postId, String body) {
this.postId = postId;
this.body = body;
}
public CommentCreateRequestDTO(Long postId, String body, Long replyComment) {
this.postId = postId;
this.body = body;
this.replyComment = replyComment;
}
public Long getPostId() {
return postId;
}
public void setPostId(Long postId) {
this.postId = postId;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public Long getReplyComment() {
return replyComment;
}
public void setReplyComment(Long replyComment) {
this.replyComment = replyComment;
}
}

View File

@ -0,0 +1,116 @@
package online.wesal.wesal.dto;
import online.wesal.wesal.entity.Comment;
import online.wesal.wesal.entity.User;
import java.time.LocalDateTime;
import java.util.List;
import java.util.ArrayList;
public class CommentResponseDTO {
private String id;
private String postId;
private CreatorDTO creator;
private String body;
private LocalDateTime creationDate;
private String replyComment;
private String displayReplyComment;
private CreatorDTO replyUser;
private Integer level;
private List<CommentResponseDTO> replies;
public CommentResponseDTO() {}
public CommentResponseDTO(Comment comment, User creator, User replyUser) {
this.id = String.valueOf(comment.getId());
this.postId = String.valueOf(comment.getPostId());
this.creator = new CreatorDTO(creator);
this.body = comment.getBody();
this.creationDate = comment.getCreationDate();
this.replyComment = comment.getReplyComment() != null ? String.valueOf(comment.getReplyComment()) : null;
this.displayReplyComment = comment.getDisplayReplyComment() != null ? String.valueOf(comment.getDisplayReplyComment()) : null;
this.replyUser = replyUser != null ? new CreatorDTO(replyUser) : null;
this.level = comment.getLevel();
this.replies = new ArrayList<>();
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getPostId() {
return postId;
}
public void setPostId(String postId) {
this.postId = postId;
}
public CreatorDTO getCreator() {
return creator;
}
public void setCreator(CreatorDTO creator) {
this.creator = creator;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public LocalDateTime getCreationDate() {
return creationDate;
}
public void setCreationDate(LocalDateTime creationDate) {
this.creationDate = creationDate;
}
public String getReplyComment() {
return replyComment;
}
public void setReplyComment(String replyComment) {
this.replyComment = replyComment;
}
public String getDisplayReplyComment() {
return displayReplyComment;
}
public void setDisplayReplyComment(String displayReplyComment) {
this.displayReplyComment = displayReplyComment;
}
public CreatorDTO getReplyUser() {
return replyUser;
}
public void setReplyUser(CreatorDTO replyUser) {
this.replyUser = replyUser;
}
public Integer getLevel() {
return level;
}
public void setLevel(Integer level) {
this.level = level;
}
public List<CommentResponseDTO> getReplies() {
return replies;
}
public void setReplies(List<CommentResponseDTO> replies) {
this.replies = replies;
}
}

View File

@ -0,0 +1,130 @@
package online.wesal.wesal.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
@Entity
@Table(name = "comments")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, name = "post_id")
private Long postId;
@Column(nullable = false, name = "creator_id")
private Long creatorId;
@Column(nullable = false, length = 1000)
@NotBlank
private String body;
@Column(nullable = false)
private LocalDateTime creationDate = LocalDateTime.now();
@Column(name = "reply_comment")
private Long replyComment;
@Column(name = "display_reply_comment")
private Long displayReplyComment;
@Column(name = "reply_user")
private Long replyUser;
@Column(nullable = false)
private Integer level = 1;
public Comment() {}
public Comment(Long postId, Long creatorId, String body) {
this.postId = postId;
this.creatorId = creatorId;
this.body = body;
this.level = 1;
}
public Comment(Long postId, Long creatorId, String body, Long replyComment, Long displayReplyComment, Long replyUser, Integer level) {
this.postId = postId;
this.creatorId = creatorId;
this.body = body;
this.replyComment = replyComment;
this.displayReplyComment = displayReplyComment;
this.replyUser = replyUser;
this.level = level;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getPostId() {
return postId;
}
public void setPostId(Long postId) {
this.postId = postId;
}
public Long getCreatorId() {
return creatorId;
}
public void setCreatorId(Long creatorId) {
this.creatorId = creatorId;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public LocalDateTime getCreationDate() {
return creationDate;
}
public void setCreationDate(LocalDateTime creationDate) {
this.creationDate = creationDate;
}
public Long getReplyComment() {
return replyComment;
}
public void setReplyComment(Long replyComment) {
this.replyComment = replyComment;
}
public Long getDisplayReplyComment() {
return displayReplyComment;
}
public void setDisplayReplyComment(Long displayReplyComment) {
this.displayReplyComment = displayReplyComment;
}
public Long getReplyUser() {
return replyUser;
}
public void setReplyUser(Long replyUser) {
this.replyUser = replyUser;
}
public Integer getLevel() {
return level;
}
public void setLevel(Integer level) {
this.level = level;
}
}

View File

@ -0,0 +1,26 @@
package online.wesal.wesal.repository;
import online.wesal.wesal.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostIdOrderByCreationDateDesc(Long postId);
@Query("SELECT c FROM Comment c WHERE c.postId = :postId ORDER BY c.creationDate ASC")
List<Comment> findByPostIdOrderByCreationDateAsc(@Param("postId") Long postId);
Optional<Comment> findByIdAndPostId(Long id, Long postId);
long countByPostId(Long postId);
@Query("SELECT c FROM Comment c WHERE c.replyComment = :replyComment ORDER BY c.creationDate ASC")
List<Comment> findRepliesByReplyComment(@Param("replyComment") Long replyComment);
}

View File

@ -0,0 +1,136 @@
package online.wesal.wesal.service;
import online.wesal.wesal.dto.CommentResponseDTO;
import online.wesal.wesal.entity.Comment;
import online.wesal.wesal.entity.Post;
import online.wesal.wesal.entity.User;
import online.wesal.wesal.repository.CommentRepository;
import online.wesal.wesal.repository.PostRepository;
import online.wesal.wesal.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class CommentService {
@Autowired
private CommentRepository commentRepository;
@Autowired
private PostRepository postRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private UserService userService;
@Transactional
public CommentResponseDTO createComment(Long postId, String body, Long replyComment) {
User currentUser = userService.getCurrentUser();
Post post = postRepository.findById(postId)
.orElseThrow(() -> new RuntimeException("Post not found"));
Comment comment;
User replyUser = null;
if (replyComment == null) {
comment = new Comment(postId, currentUser.getId(), body);
} else {
Comment parentComment = commentRepository.findByIdAndPostId(replyComment, postId)
.orElseThrow(() -> new RuntimeException("Parent comment not found"));
replyUser = userRepository.findById(parentComment.getCreatorId())
.orElseThrow(() -> new RuntimeException("Parent comment creator not found"));
Integer newLevel;
Long displayReplyComment;
if (parentComment.getLevel() >= 3) {
newLevel = 3;
Comment rootComment = findRootCommentForLevel3(parentComment);
displayReplyComment = rootComment.getId();
} else {
newLevel = parentComment.getLevel() + 1;
displayReplyComment = replyComment;
}
comment = new Comment(postId, currentUser.getId(), body, replyComment, displayReplyComment, replyUser.getId(), newLevel);
}
comment = commentRepository.save(comment);
post.setComments(post.getComments() + 1);
postRepository.save(post);
return new CommentResponseDTO(comment, currentUser, replyUser);
}
private Comment findRootCommentForLevel3(Comment comment) {
if (comment.getLevel() <= 2) {
return comment;
}
if (comment.getReplyComment() != null) {
Comment parentComment = commentRepository.findById(comment.getReplyComment())
.orElse(comment);
return findRootCommentForLevel3(parentComment);
}
return comment;
}
public List<CommentResponseDTO> getCommentsByPostId(Long postId) {
List<Comment> comments = commentRepository.findByPostIdOrderByCreationDateAsc(postId);
List<Long> creatorIds = comments.stream()
.map(Comment::getCreatorId)
.distinct()
.collect(Collectors.toList());
List<Long> replyUserIds = comments.stream()
.map(Comment::getReplyUser)
.filter(id -> id != null)
.distinct()
.collect(Collectors.toList());
creatorIds.addAll(replyUserIds);
Map<Long, User> users = userRepository.findAllById(creatorIds.stream().distinct().collect(Collectors.toList())).stream()
.collect(Collectors.toMap(User::getId, user -> user));
Map<Long, CommentResponseDTO> commentMap = comments.stream()
.collect(Collectors.toMap(
Comment::getId,
comment -> new CommentResponseDTO(comment,
users.get(comment.getCreatorId()),
comment.getReplyUser() != null ? users.get(comment.getReplyUser()) : null)
));
List<CommentResponseDTO> rootComments = new ArrayList<>();
for (Comment comment : comments) {
CommentResponseDTO dto = commentMap.get(comment.getId());
if (comment.getDisplayReplyComment() == null) {
rootComments.add(dto);
} else {
CommentResponseDTO parent = commentMap.get(comment.getDisplayReplyComment());
if (parent != null) {
parent.getReplies().add(dto);
} else {
rootComments.add(dto);
}
}
}
return rootComments;
}
}

View File

@ -7,6 +7,8 @@ import 'services/notification_service.dart';
import 'services/auth_service.dart';
import 'services/user_service.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
@ -21,6 +23,7 @@ class MyApp extends StatelessWidget {
title: 'Wesal',
theme: ThemeData(primarySwatch: Colors.blue, fontFamily: 'Roboto'),
home: SplashScreen(),
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false,
);
}

View File

@ -1,7 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import '../constants/api_constants.dart';
import '../main.dart';
class AuthService {
static const FlutterSecureStorage _storage = FlutterSecureStorage();
@ -87,4 +89,13 @@ class AuthService {
static Future<void> clearUserData() async {
await _storage.delete(key: _userDataKey);
}
static Future<void> handleAuthenticationError() async {
await logout();
final context = navigatorKey.currentContext;
if (context != null && context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
}
}
}

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import '../constants/api_constants.dart';
import '../models/invitation_models.dart';
import 'http_service.dart';
import 'auth_service.dart';
class InvitationsService {
static Future<Map<String, dynamic>> getAllInvitations() async {
@ -23,16 +24,12 @@ class InvitationsService {
invitationsResponse.message ?? 'Failed to fetch invitations',
};
}
} else if (response.statusCode == 401) {
} else if (response.statusCode == 401 || response.statusCode == 403) {
await AuthService.handleAuthenticationError();
return {
'success': false,
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 403) {
return {
'success': false,
'message': 'Access denied. Invalid credentials.',
};
} else {
return {
'success': false,

View File

@ -1,15 +1,15 @@
import 'dart:convert';
import '../models/post_models.dart';
import 'http_service.dart';
import 'auth_service.dart';
class PostService {
static Future<Map<String, dynamic>> getAllPosts() async {
try {
final response = await HttpService.get('/posts/all');
final responseData = jsonDecode(response.body);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
return {
'success': responseData['status'] ?? false,
'message': responseData['message'] ?? '',
@ -19,12 +19,28 @@ class PostService {
.toList() ??
[],
};
} else {
} else if (response.statusCode == 401 || response.statusCode == 403) {
await AuthService.handleAuthenticationError();
return {
'success': false,
'message': responseData['message'] ?? 'Failed to fetch posts',
'message': 'Session expired. Please login again.',
'posts': <Post>[],
};
} else {
try {
final responseData = jsonDecode(response.body);
return {
'success': false,
'message': responseData['message'] ?? 'Failed to fetch posts',
'posts': <Post>[],
};
} catch (e) {
return {
'success': false,
'message': 'Server error (${response.statusCode})',
'posts': <Post>[],
};
}
}
} catch (e) {
print('Error fetching posts: $e');

View File

@ -21,16 +21,12 @@ class UserService {
final data = jsonDecode(response.body);
await AuthService.saveUserData(data);
return {'success': true, 'data': data};
} else if (response.statusCode == 401) {
} else if (response.statusCode == 401 || response.statusCode == 403) {
await AuthService.handleAuthenticationError();
return {
'success': false,
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 403) {
return {
'success': false,
'message': 'Access denied. Invalid credentials.',
};
} else {
return {
'success': false,